text_to_solver_program/checks/model_checks.py
dschlueter f101b63947 fix: Solver-Fehler gilt als unzuverlässig; Aufräumrunde ohne LLM
- Status error nach allen Reparaturen -> reliable=False („Solver-Fehler: …“, auch bei
  --resolve); vorher Exit-Code 0 ohne Warnkasten. Benchmark zählt einen Absturz nicht
  als ehrliches Scheitern.
- Report: „längster Weg“ bei maximize; Harness druckt „Gefundener Weg“.
- check_requested_method: Verneinung kurz davor im selben Satz („ohne/nicht/statt/
  anders als Dijkstra“) ist keine Forderung.
- Reparatur-Prompt enthält die Fehlertexte aller früheren Versuche.
- ruff (F, E9) als Test tests/test_lint.py, Konfiguration in pyproject.toml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:15:22 +02:00

590 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Deterministische Plausibilitätsprüfungen einer Modellspezifikation (ohne LLM).
error -> löst in run_pipeline eine Korrekturrunde aus
warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen)
"""
import re
from collections import Counter
from typing import Iterable, List, Set
from core.model_schema import (
CheckIssue,
CheckSeverity,
OptimizationModelSpecification,
ProblemClass,
TargetStack,
VariableType,
)
# Zulässige Ziel-Stacks je Problemklasse (muss zu den Routing-Regeln im Prompt passen)
ALLOWED_STACKS = {
ProblemClass.LP: {TargetStack.SCIPY, TargetStack.HIGHS},
ProblemClass.MILP: {TargetStack.HIGHS},
ProblemClass.CP: {TargetStack.OR_TOOLS_CPSAT},
ProblemClass.QP: {TargetStack.CVXPY},
ProblemClass.SOCP: {TargetStack.CVXPY},
ProblemClass.MIQP: {TargetStack.SCIP},
ProblemClass.NLP: {TargetStack.IPOPT, TargetStack.SCIP},
# Kürzeste Wege: spezialisierter Graphalgorithmus, alternativ als Flussmodell (MILP)
ProblemClass.SHORTEST_PATH: {TargetStack.NETWORKX, TargetStack.HIGHS},
}
LINEAR_CLASSES = {ProblemClass.LP, ProblemClass.MILP, ProblemClass.CP, ProblemClass.SHORTEST_PATH}
# Quadrat, Transponierte (w^T \Sigma w) oder Norm
NONLINEAR_PATTERN = re.compile(r"\^\s*\{?\s*(2|T)\b|\\\||\\lVert|\|\|")
# Grenzen von Summen/Produkten/Integralen: \sum_{i=0}^{2} ist keine Potenz, sondern ein Laufbereich
LIMIT_PATTERN = re.compile(r"\\(sum|prod|int|iint|oint|bigcup|bigcap|max|min|argmax|argmin)"
r"(\s*_\s*(\{[^{}]*\}|\\?\w))?(\s*\^\s*(\{[^{}]*\}|\\?\w))?")
GREEK = {
"alpha", "beta", "gamma", "delta", "epsilon", "varepsilon", "zeta", "eta", "theta", "vartheta",
"iota", "kappa", "lambda", "mu", "nu", "xi", "pi", "rho", "sigma", "tau", "upsilon", "phi",
"varphi", "chi", "psi", "omega", "Gamma", "Delta", "Theta", "Lambda", "Xi", "Pi", "Sigma",
"Upsilon", "Phi", "Psi", "Omega",
}
# Wörter, die in Formeln vorkommen dürfen, ohne deklariert zu sein
IGNORED_TOKENS = {"sum", "min", "max", "abs", "log", "exp", "sqrt", "for", "all", "if", "then", "and", "or", "not"}
# Einheiten je Parameter (ParameterDefinition.unit) – nur zwei Klassen werden gegeneinander geprüft:
# Geldbeträge und dimensionslose Größen. Beobachtet: Risiko als \sum w_i^2 \sigma_i^2 (dimensionslos)
# neben Kosten in Euro – das Risiko war um den Faktor Kapital zu klein und damit wirkungslos.
CURRENCY_UNIT = re.compile(r"€|\bEUR\b|Euro|\$|\bUSD\b|Geldeinheit|Kosteneinheit|\bKE\b|\bGE\b", re.IGNORECASE)
RELATIVE_UNIT = re.compile(r"^\s*(-|–|1)?\s*$|%|Prozent|Anteil|Quote|Faktor|Verhältnis|dimensionslos|"
r"dimensionless|ohne Einheit|einheitenlos", re.IGNORECASE)
# Fachbegriffe, die ein Leser ohne OR-Ausbildung nicht mitbringt. Tauchen sie dort auf, wo er sie sieht
# (Restriktionsnamen, Tabellenspalten, Antwortsatz), müssen sie in executive_summary erklärt sein.
# Je Begriff mehrere Wortstämme, damit Beugungen ("Bogen"/"Bögen") als derselbe Begriff zählen.
JARGON = {
"Bogen": ("bogen", "bög"),
"Knoten": ("knoten",),
"Kante": ("kante",),
"Fluss": ("fluss", "flüss"),
"Quelle/Senke": ("senke",),
"Relaxation": ("relaxation", "relaxier"),
"Schlupf": ("schlupf", "slack"),
"Kardinalität": ("kardinal",),
"Epigraph": ("epigraph",),
"Schattenpreis": ("schattenpreis", "dualwert", "dualvariable"),
"Konvexität": ("konvex",),
# nicht "markier": „die mit ja markierten Zeilen“ ist Alltagssprache (Fehlalarm in 20260917-160618)
"Markierung": ("marke", "markierung"),
"Inzidenz": ("inzidenz",),
"Big-M": ("big-m", "big m"),
"Subtour": ("subtour",),
"Lagrange": ("lagrange",),
}
# Alltagswörter, die einen Fachbegriff-Stamm enthalten. Sie zählen weder als Vorkommen noch als Erklärung –
# beobachtet: „beeinflusst“ in der Zusammenfassung galt als Erklärung von „Flusserhaltung“ (20260917-160618).
JARGON_EVERYDAY = re.compile(r"beeinfluss|einfluss|überfluss|ueberfluss|flussdiagramm")
def unexplained_jargon(sichtbar: str, erklaerung: str) -> List[str]:
"""Fachbegriffe, die im sichtbaren Text vorkommen, in der Erklärung aber nicht."""
sichtbar = JARGON_EVERYDAY.sub(" ", sichtbar.lower())
erklaerung = JARGON_EVERYDAY.sub(" ", erklaerung.lower())
return sorted(name for name, staemme in JARGON.items()
if any(s in sichtbar for s in staemme) and not any(s in erklaerung for s in staemme))
def check_jargon(spec: OptimizationModelSpecification) -> List[CheckIssue]:
"""Fachbegriffe in Namen des Modells, die die Zusammenfassung nicht einführt (siehe Block ERKLÄREN)."""
sichtbar = " ".join([*(c.identifier for c in spec.constraints), *(v.name for v in spec.variables),
*spec.indices])
offen = unexplained_jargon(sichtbar, spec.executive_summary)
if not offen:
return []
return [CheckIssue(
severity=CheckSeverity.WARNING, code="UNEXPLAINED_JARGON",
message=f"Fachbegriffe im Modell, die executive_summary nicht erklärt: {', '.join(offen)}. "
f"Der Leser trifft sie in Restriktions- und Variablennamen, ohne zu wissen, was sie bedeuten.",
)]
# --- Verstehensstufe: Teilfragen und Zahlen des Aufgabentexts (ohne LLM) ---
# Zahl im Fließtext: 1.234,56 / 0,05 / 12 / 3.8 – ohne Zahlen, die Teil eines Wortes sind (A02, x1)
NUMBER_PATTERN = re.compile(r"(?<![A-Za-z0-9_])\d+(?:[.,]\d+)*(?![A-Za-z0-9_])")
# Teilfragen-Marker am Zeilenanfang: "a)", "- b)", "**Teil 2:**", "**Teil 2:** Text", "1." – bewusst eng, um
# Aufzählungen im Fließtext nicht als Frage zu zählen. Englische Marker ("Part 2", "Question 1", "Task 3")
# gehören dazu: sonst fehlt bei englischen Aufgaben das Nachfassen bei übersehenen Teilfragen.
QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:#{1,6}\s*)?(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\**\s+\S",
re.MULTILINE)
def normalize_number(roh: str) -> str:
"""'1.234,56' -> '1234.56', '3.8' -> '3.8', '1.234' -> '1234'.
Der Punkt ist mehrdeutig: Tausendertrenner (deutsch) oder Dezimalpunkt (englisch, Nummerierung).
Entschieden wird am Komma (dann ist der Punkt Tausendertrenner) und sonst an der Gruppenlänge:
genau drei Ziffern hinter dem letzten Punkt heißt Tausender, alles andere Dezimalpunkt.
Englische Schreibweise wird erkannt, wo sie eindeutig ist: '1,234.56' (Punkt nach dem Komma) und
'12,345,678' (mehrere Dreiergruppen). Ein einzelnes '1,500' bleibt deutsch gelesen (1,5) – beides ist
möglich, und die Pipeline ist auf deutsche Aufgaben ausgelegt. Vorher wurde '1,234.56' zu 1.23456.
"""
englisch = (("." in roh and roh.rfind(".") > roh.rfind(",") > -1) # 1,234.56
or (roh.count(",") > 1 and "." not in roh and all(len(t) == 3 for t in roh.split(",")[1:]))) # 12,345,678
if englisch:
roh = roh.replace(",", "")
elif "," in roh:
roh = roh.replace(".", "").replace(",", ".")
elif "." in roh:
teile = roh.split(".")
# „0.040“ ist nie eine Tausenderschreibweise – vorher wurde es zu 40 (portfolio_qp, Rückrechnung 17.09.2026)
if all(len(t) == 3 for t in teile[1:]) and len(teile[0]) <= 3 and teile[0] != "0":
roh = "".join(teile) # 1.234 bzw. 12.345.678
wert = float(roh)
return str(int(wert)) if wert.is_integer() else f"{wert:g}" # große Zahlen exakt, nicht 1.23457e+06
def numbers_in(text: str) -> Set[str]:
"""Zahlen eines Textes in einheitlicher Schreibweise."""
gefunden = set()
# LaTeX schreibt das Dezimalkomma als {,} (0{,}08) – sonst zerfiele die Zahl in 0 und 08
text = re.sub(r"(?<=\d)\{,\}(?=\d)", ",", text or "")
for roh in NUMBER_PATTERN.findall(text):
try:
gefunden.add(normalize_number(roh))
except ValueError:
continue
return gefunden
def question_ids_in(text: str) -> Set[str]:
"""Kennungen der Teilfragen, die im Aufgabentext erkennbar sind.
Nennt der Text Teilfragen ausdrücklich („### Teil 3“, „Aufgabe 2“), zählen bloße Nummerierungen („1.“) nicht –
beobachtet: eine nummerierte Liste von Restriktionen galt als acht Teilfragen, die als Überschriften gesetzten
eigentlichen Teilaufgaben wurden übersehen.
"""
treffer = QUESTION_PATTERN.findall(text or "")
ausdruecklich = any(b for _, b, _ in treffer)
return {(a or b or c).lower() for a, b, c in treffer if not (ausdruecklich and c and not (a or b))}
def _covers(genannt: str, erkannt: str) -> bool:
"""'2a', '2.1', 'a1' decken die Teilfrage '2' bzw. 'a' ab – '21' deckt '2' nicht ab.
Beobachtet an „Portfolio Nr. 2": Teil 2 wurde in 2a und 2b zerlegt, gemeldet wurde „es fehlt: 2".
"""
if genannt == erkannt:
return True
if not genannt.startswith(erkannt):
return False
naechstes = genannt[len(erkannt)]
return naechstes.isdigit() != erkannt[-1].isdigit() or not naechstes.isalnum()
def check_understanding(understanding, task: str) -> List[CheckIssue]:
"""Prüft das Verständnis gegen den Aufgabentext: sind alle Zahlen und alle Teilfragen erfasst?
Zahlen gelten als erfasst, wenn sie irgendwo im Verständnis auftauchen (Wert, Zitat, Bedeutung)
oder ausdrücklich als irrelevant erklärt sind – ein Zitat der Tabellenzeile deckt so eine ganze
Datenzeile ab, ohne dass jede Zahl einzeln aufgeführt werden muss.
"""
issues = []
erfasst = numbers_in(understanding.model_dump_json())
fehlend = sorted(numbers_in(task) - erfasst, key=float)
if fehlend:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="UNCAPTURED_NUMBERS",
message=f"Zahlen aus dem Aufgabentext, die weder als Fakt noch als irrelevant erfasst sind: "
f"{', '.join(fehlend[:12])}{' …' if len(fehlend) > 12 else ''}. "
f"Sie fehlen damit in der Spezifikation und können nicht geprüft werden.",
))
erkannt = question_ids_in(task)
genannt = {q.id.lower().removeprefix("teil ").removeprefix("aufgabe ").strip(" ().") for q in understanding.sub_questions}
offen = sorted(e for e in erkannt if not any(_covers(g, e) for g in genannt))
if offen:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="MISSING_SUB_QUESTIONS",
message=f"Der Aufgabentext enthält die Teilfragen {', '.join(sorted(erkannt))}, "
f"das Verständnis nennt {', '.join(sorted(genannt)) or 'keine'} – es fehlen: {', '.join(offen)}.",
))
if not understanding.sub_questions:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="NO_SUB_QUESTIONS",
message="Keine einzige Teilfrage erfasst – auch eine Aufgabe mit nur einer Frage braucht einen Eintrag.",
))
return issues
def _number_rows(text: str) -> Counter:
"""Je Datenzeile das Tupel ihrer Zahlen: „Pfeil 1 → 2: 3“ und „(1,2): 3“ ergeben beide (1, 2, 3).
Zeilen trennt der Zeilenumbruch oder ein Semikolon; Zeilen ohne Zahl zählen nicht. So ist der Vergleich
unabhängig von der Schreibweise, bemerkt aber eine Bewertung am falschen Pfeil.
"""
zeilen = Counter()
# „(1,2)“ ist ein Pfeil, keine Dezimalzahl 1,2 – ganzzahlige Paare in Klammern vorher trennen
text = re.sub(r"\((\d+)\s*,\s*(\d+)\)", r"(\1 \2)", text or "")
for zeile in re.split(r"[\n;]", text):
tupel = []
for roh in NUMBER_PATTERN.findall(zeile):
try:
tupel.append(normalize_number(roh))
except ValueError:
continue
if tupel:
zeilen[tuple(tupel)] += 1
return zeilen
def check_figure_readings(first, second) -> List[CheckIssue]:
"""Zwei unabhängige Übertragungen derselben Abbildungen vergleichen – Zahl für Zahl, ohne LLM.
Ein Bildmodell kann eine Pfeilbewertung dem falschen Pfeil zuordnen oder eine Ziffer verlesen; eine einzige
Lesung wäre dann ungeprüft die Datengrundlage jeder weiteren Stufe. Verglichen werden die Datenzeilen als
Zahlentupel (`_number_rows`). Erster Ansatz – alle Zahlen des Freitexts – schlug im ersten echten Lauf
fälschlich an: eine Lesung zählte zusätzlich „Knoten: 1, 2, 3, 4“ auf (tasks/wegenetz_mit_abbildung.md, 17.09.2026).
Eine vertauschte Pfeilrichtung (2 → 3 statt 3 → 2) fällt auf; eine in BEIDEN Lesungen gleich falsche nicht.
"""
from understanding.figures import canonical_figure_id
issues = []
erste = {canonical_figure_id(f.id): f for f in first}
zweite = {canonical_figure_id(f.id): f for f in second}
for kennung in sorted(set(erste) | set(zweite)):
if kennung not in erste or kennung not in zweite:
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
message=f"{kennung} wurde nur in einer von zwei Lesungen übertragen."))
continue
a = _number_rows(erste[kennung].transcription)
b = _number_rows(zweite[kennung].transcription)
if a != b:
zeilen = lambda z: ", ".join("(" + " ".join(t) + ")" for t in sorted(z.elements())) or "–"
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
message=f"{kennung} uneinheitlich gelesen – Datenzeilen nur in Lesung 1: {zeilen(a - b)}; "
f"nur in Lesung 2: {zeilen(b - a)}."))
return issues
def _strip_scripts(text: str) -> str:
"""Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x."""
text = text.replace(r"\_", "_")
# Schriftbefehle samt Inhalt: \mathbb{Z} ist die Menge der ganzen Zahlen, kein Symbol namens Z
text = re.sub(r"\\(text|mathrm|operatorname|mbox|mathbb|mathcal|mathbf|mathsf|boldsymbol)\s*\{[^{}]*\}", " ", text)
previous = None
while previous != text: # verschachtelte Klammern von innen nach außen
previous = text
text = re.sub(r"[_^]\s*\{[^{}]*\}", "", text)
# einzelnes Zeichen als Index, aber nicht innerhalb von Namen wie min_staff
text = re.sub(r"[_^]\s*\\?[A-Za-z0-9](?![A-Za-z0-9])", "", text)
return text
def _symbols(text: str) -> Set[str]:
"""Basis-Symbole einer LaTeX-Formel (Greek als Symbol, andere Befehle ignoriert)."""
text = _strip_scripts(text)
tokens = set()
for match in re.finditer(r"\\([A-Za-z]+)|([A-Za-z][A-Za-z0-9_]*)", text):
command, name = match.groups()
if command:
if command in GREEK:
tokens.add("\\" + command)
else:
tokens.add(name.rstrip("_"))
return tokens
def _declared_symbols(spec: OptimizationModelSpecification) -> Set[str]:
declared: Set[str] = set()
for text in _texts_declaring(spec):
declared |= _symbols(text)
return declared
def _texts_declaring(spec: OptimizationModelSpecification) -> Iterable[str]:
yield from (v.name for v in spec.variables)
yield from (p.symbol for p in spec.parameters)
for index in spec.indices:
# Formel-Begrenzer entfernen ("Tage: \(d \in D\)"), dann erläuternde Klammer am Ende
# ("i in I (Mitarbeiter)"); früher wurde am ersten "(" geschnitten – das traf auch "\("
text = re.sub(r"\\[()\[\]]|\$", " ", index)
yield re.sub(r"\s\([^()]*\)\s*$", "", text)
def _objective_without_label(formula: str) -> str:
"""'Z = 12 x': der Name links vom Gleichheitszeichen ist eine Beschriftung, kein Symbol des Modells.
Nur für die Zielfunktion – bei Restriktionen ist die linke Seite Teil des Modells.
"""
# Nur ein Gleichheitszeichen auf oberster Klammerebene zählt: \sum_{i=0}^{2} enthält ebenfalls eines,
# und daran zerschnitten blieb von der Formel „0}^{2}\sum…“ übrig (führte zu NONLINEAR_TERM).
tiefe = 0
for pos, zeichen in enumerate(formula):
if zeichen in "{([":
tiefe += 1
elif zeichen in "})]":
tiefe -= 1
elif zeichen == "=" and tiefe == 0:
left, right = formula[:pos], formula[pos + 1:]
return right if len(_symbols(left) - IGNORED_TOKENS) <= 1 else formula
return formula
def _formulas(spec: OptimizationModelSpecification) -> Iterable[tuple]:
yield "Zielfunktion", _objective_without_label(spec.objective.latex_formula)
for c in spec.constraints:
yield c.identifier, c.latex_formula
def check_routing(spec: OptimizationModelSpecification) -> List[CheckIssue]:
issues = []
var_types = {v.var_type for v in spec.variables}
has_discrete = bool(var_types & {VariableType.INTEGER, VariableType.BINARY})
has_continuous = VariableType.CONTINUOUS in var_types
allowed = ALLOWED_STACKS[spec.problem_class]
if spec.target_stack not in allowed:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="STACK_MISMATCH",
message=f"Problemklasse '{spec.problem_class.value}' passt nicht zu Stack '{spec.target_stack.value}' "
f"(erlaubt: {', '.join(s.value for s in allowed)}).",
))
if has_discrete and spec.problem_class == ProblemClass.LP:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="LP_WITH_DISCRETE_VARS",
message="Problemklasse LP, aber ganzzahlige/binäre Variablen vorhanden (→ MILP oder CP).",
))
if has_discrete and spec.target_stack == TargetStack.CVXPY:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="CVXPY_WITH_DISCRETE_VARS",
message="CVXPY ist hier nur für kontinuierliche QP/SOCP vorgesehen; quadratisch + ganzzahlig/binär "
"ist MIQP (Stack SCIP).",
))
if spec.problem_class == ProblemClass.MIQP and not has_discrete:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="MIQP_WITHOUT_DISCRETE_VARS",
message="Problemklasse MIQP, aber keine ganzzahligen/binären Variablen (→ eigentlich QP/SOCP mit CVXPY).",
))
if has_discrete and spec.target_stack == TargetStack.IPOPT:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="IPOPT_WITH_DISCRETE_VARS",
message="Ipopt löst nur stetige Probleme; mit ganzzahligen/binären Variablen ist SCIP zuständig.",
))
if has_discrete and spec.target_stack == TargetStack.SCIPY:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="SCIPY_WITH_DISCRETE_VARS",
message="scipy.optimize.linprog unterstützt keine ganzzahligen/binären Variablen.",
))
if has_continuous and spec.target_stack == TargetStack.OR_TOOLS_CPSAT:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="CPSAT_WITH_CONTINUOUS_VARS",
message="CP-SAT unterstützt nur ganzzahlige/binäre Variablen, es gibt aber kontinuierliche.",
))
if spec.problem_class == ProblemClass.MILP and not has_discrete:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="MILP_WITHOUT_DISCRETE_VARS",
message="Problemklasse MILP, aber keine ganzzahligen/binären Variablen (→ eigentlich LP).",
))
return issues
def check_nonlinearity(spec: OptimizationModelSpecification) -> List[CheckIssue]:
if spec.problem_class not in LINEAR_CLASSES:
return []
return [
CheckIssue(
severity=CheckSeverity.WARNING, code="NONLINEAR_TERM",
message=f"'{name}' enthält einen möglicherweise nichtlinearen Term, "
f"Problemklasse ist aber '{spec.problem_class.value}': {formula}",
)
for name, formula in _formulas(spec)
if NONLINEAR_PATTERN.search(LIMIT_PATTERN.sub(" ", formula))
]
def _is_implicit_product(symbol: str, declared: Set[str]) -> bool:
"""'cx' in \\sin(cx) ist das Produkt c·x, kein eigener Name – wenn c und x deklariert sind."""
return len(symbol) > 1 and all(zeichen in declared for zeichen in symbol)
def check_undeclared_symbols(spec: OptimizationModelSpecification) -> List[CheckIssue]:
declared = _declared_symbols(spec)
undeclared: dict = {}
for name, formula in _formulas(spec):
for symbol in _symbols(formula) - declared - IGNORED_TOKENS:
if _is_implicit_product(symbol, declared):
continue
undeclared.setdefault(symbol, []).append(name)
return [
CheckIssue(
severity=CheckSeverity.WARNING, code="UNDECLARED_SYMBOL",
message=f"Symbol '{symbol}' wird in {', '.join(places)} verwendet, ist aber weder Variable, Parameter noch Index.",
)
for symbol, places in sorted(undeclared.items())
]
def _unit_class(unit: str) -> str:
"""'currency', 'relative' oder 'unbekannt'; nur die ersten beiden werden gegeneinander geprüft."""
text = (unit or "").strip()
if CURRENCY_UNIT.search(text):
return "currency"
if RELATIVE_UNIT.search(text):
return "relative"
return "unbekannt"
def _additive_terms(formula: str) -> List[str]:
"""Summanden einer Formel: Trennung an + und - auf oberster Klammerebene.
Indizes stehen in Klammern (\\sum_{i \\in I}) und werden dadurch nicht zerschnitten.
"""
body = re.sub(r"\\operatorname\*?\s*\{[^{}]*\}|\\(?:min|max|left|right|quad|qquad)\b|\\[,;!]", " ", formula)
if "=" in body: # "\min z = \sum ..." -> nur die rechte Seite ist die Zielfunktion
body = body.rsplit("=", 1)[1]
terms, current, depth = [], "", 0
for char in body:
if char in "{([":
depth += 1
elif char in "})]":
depth -= 1
if depth == 0 and char in "+-" and current.strip():
terms.append(current)
current = ""
continue
current += char
return [t for t in (*terms, current) if t.strip()]
def _term_unit_class(term: str, parameter_units: dict) -> str:
"""Einheit eines Summanden: ein Geldbetrag mal dimensionslosem Faktor bleibt ein Geldbetrag."""
classes = {c for symbol in _symbols(term) for c in parameter_units.get(symbol, ())}
if "currency" in classes:
return "currency"
if classes == {"relative"}:
return "relative"
return "unbekannt"
def check_objective_units(spec: OptimizationModelSpecification) -> List[CheckIssue]:
"""Mischt die Zielfunktion Geldbeträge mit dimensionslosen Größen? (aus ParameterDefinition.unit)"""
parameter_units: dict = {}
for parameter in spec.parameters:
for symbol in _symbols(parameter.symbol):
parameter_units.setdefault(symbol, set()).add(_unit_class(parameter.unit))
by_class: dict = {}
for term in _additive_terms(spec.objective.latex_formula):
unit_class = _term_unit_class(term, parameter_units)
if unit_class != "unbekannt":
by_class.setdefault(unit_class, []).append(term.strip())
if "currency" not in by_class or "relative" not in by_class:
return []
return [CheckIssue(
severity=CheckSeverity.ERROR, code="MIXED_OBJECTIVE_UNITS",
message=f"Die Zielfunktion mischt Geldbeträge ({'; '.join(by_class['currency'])}) mit dimensionslosen "
f"Termen ({'; '.join(by_class['relative'])}). Der dimensionslose Term ist gegenüber den "
f"Geldbeträgen praktisch wirkungslos – mit dem Kapital bzw. Mengengerüst multiplizieren.",
)]
# Nennt der Aufgabentext ein Verfahren beim Namen, ist es Teil der Aufgabenstellung – ein gleichwertiges
# Modell erfüllt sie dann nicht (beobachtet: „Lösen Sie mit dem Dijkstra-Algorithmus“ wurde als
# MILP-Flussmodell gelöst, mathematisch richtig, als Aufgabe aber verfehlt).
REQUESTED_METHOD = {
"dijkstra": {TargetStack.NETWORKX},
"bellman-ford": {TargetStack.NETWORKX, TargetStack.HIGHS},
"kürzeste-wege-problem": {TargetStack.NETWORKX, TargetStack.HIGHS},
"simplex": {TargetStack.SCIPY, TargetStack.HIGHS},
"branch-and-bound": {TargetStack.HIGHS, TargetStack.SCIP},
"branch-and-cut": {TargetStack.HIGHS, TargetStack.SCIP},
"constraint programming": {TargetStack.OR_TOOLS_CPSAT},
}
# „ohne Dijkstra“, „nicht mit dem Simplex“, „statt Branch-and-Bound“: Erwähnung, keine Forderung. Nur im selben
# Satz und kurz davor – „Lösen Sie mit Dijkstra. Ohne Rechenweg gibt es keine Punkte.“ bleibt eine Forderung.
NEGATION_BEFORE = re.compile(r"\b(?:ohne|nicht|keine?[nmrs]?|statt|anstelle|anstatt|außer|ausser|anders als)\b[^.!?;\n]{0,30}$")
def _requested(name: str, text: str) -> bool:
"""Kommt das Verfahren mindestens einmal ohne vorangehende Verneinung vor?"""
return any(not NEGATION_BEFORE.search(text[:m.start()]) for m in re.finditer(re.escape(name), text))
def check_requested_method(spec: OptimizationModelSpecification, task: str) -> List[CheckIssue]:
"""Ein im Aufgabentext ausdrücklich verlangtes Verfahren muss auch verwendet werden."""
text = (task or "").lower()
issues = []
for name, stacks in REQUESTED_METHOD.items():
if _requested(name, text) and spec.target_stack not in stacks:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="REQUESTED_METHOD_IGNORED",
message=f"Die Aufgabe verlangt ausdrücklich '{name}', das Modell nutzt aber "
f"'{spec.target_stack.value}' (passend wäre: {', '.join(sorted(s.value for s in stacks))}). "
f"Ein gleichwertiges Modell erfüllt die Aufgabenstellung nicht.",
))
return issues
def check_data_transfer(spec: OptimizationModelSpecification, understanding) -> List[CheckIssue]:
"""Sind die erfassten Zahlen auch im Modell angekommen?
Beobachtet: Die Verstehensstufe erfasste die vollständige Kostenmatrix, die Spezifikation führte
`extracted_values: None` – die Werte betraten die Pipeline erst im Solver-Code, also hinter allen
Prüfungen. Ohne Werte in der Spezifikation kann nichts Numerisches geprüft werden.
"""
if understanding is None or not understanding.facts:
return []
# Nur die erklärten Werte, nicht die Zitate: eine zitierte Tabellenzeile enthält auch
# Zeilenbeschriftungen, die keine Parameterwerte sind.
erfasst = numbers_in(" ".join(f.value for f in understanding.facts))
uebernommen = numbers_in(" ".join(p.extracted_values or "" for p in spec.parameters))
if not erfasst or (erfasst & uebernommen):
return [] # bewusst tolerant: gemeldet wird nur die Spezifikation ganz ohne Zahlen
return [CheckIssue(
severity=CheckSeverity.WARNING, code="UNUSED_FACTS",
message=f"Die Verstehensstufe hat {len(erfasst)} Zahlenwerte erfasst "
f"({', '.join(sorted(erfasst, key=float)[:8])}), in extracted_values steht keiner davon. "
f"Ohne Werte ist die Spezifikation nicht nachrechenbar – die Zahlen erreichen die Pipeline "
f"sonst erst im Solver-Code, hinter allen Prüfungen.",
)]
def check_missing_data(spec: OptimizationModelSpecification) -> List[CheckIssue]:
missing = [p.symbol for p in spec.parameters if not (p.extracted_values or "").strip()]
if not missing:
return []
return [CheckIssue(
severity=CheckSeverity.WARNING, code="MISSING_DATA",
message=f"Keine Werte im Aufgabentext für: {', '.join(missing)}. Lösung nur mit angenommenen Daten möglich.",
)]
def check_structure(spec: OptimizationModelSpecification) -> List[CheckIssue]:
issues = []
if not spec.variables:
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="NO_VARIABLES", message="Keine Entscheidungsvariablen definiert."))
if not spec.constraints:
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="NO_CONSTRAINTS", message="Keine Restriktionen definiert."))
return issues
def check_spec(spec: OptimizationModelSpecification, task: str = "", understanding=None) -> List[CheckIssue]:
"""Alle deterministischen Prüfungen; `task` und `understanding` nur für Prüfungen, die sie brauchen."""
return (
check_structure(spec)
+ check_routing(spec)
+ check_nonlinearity(spec)
+ check_undeclared_symbols(spec)
+ check_objective_units(spec)
+ check_jargon(spec)
+ check_requested_method(spec, task)
+ check_data_transfer(spec, understanding)
+ check_missing_data(spec)
)
def has_errors(issues: List[CheckIssue]) -> bool:
return any(i.severity == CheckSeverity.ERROR for i in issues)