2026-09-17 11:42:58 +02:00
|
|
|
|
"""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
|
2026-09-17 17:52:52 +02:00
|
|
|
|
from collections import Counter
|
2026-09-17 11:42:58 +02:00
|
|
|
|
from typing import Iterable, List, Set
|
|
|
|
|
|
|
refactor: Pipeline-Stufen in Ordner mit ihren Prompts
Im Wurzelverzeichnis liegt nur noch TextToModel.py (Ablauf und CLI).
Jede Stufe liegt mit Code und Prompt in einem eigenen Ordner: core,
understanding, modeling, checks, solving, reporting, data, benchmark;
Tests unter tests/, Aufgaben unter tasks/. understand_task,
text_to_model/revise_model und review_assumptions/needs_work/select_best
sind aus TextToModel.py zu ihren Prompts gewandert, inhaltlich
unveraendert. Prompt-Kennung des Benchmarks bleibt 93744636095a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:11:51 +02:00
|
|
|
|
from core.model_schema import (
|
2026-09-17 11:42:58 +02:00
|
|
|
|
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",),
|
2026-09-17 16:28:39 +02:00
|
|
|
|
# nicht "markier": „die mit ja markierten Zeilen“ ist Alltagssprache (Fehlalarm in 20260917-160618)
|
|
|
|
|
|
"Markierung": ("marke", "markierung"),
|
2026-09-17 11:42:58 +02:00
|
|
|
|
"Inzidenz": ("inzidenz",),
|
|
|
|
|
|
"Big-M": ("big-m", "big m"),
|
|
|
|
|
|
"Subtour": ("subtour",),
|
|
|
|
|
|
"Lagrange": ("lagrange",),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 16:38:45 +02:00
|
|
|
|
# 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 11:42:58 +02:00
|
|
|
|
def unexplained_jargon(sichtbar: str, erklaerung: str) -> List[str]:
|
|
|
|
|
|
"""Fachbegriffe, die im sichtbaren Text vorkommen, in der Erklärung aber nicht."""
|
2026-09-17 16:38:45 +02:00
|
|
|
|
sichtbar = JARGON_EVERYDAY.sub(" ", sichtbar.lower())
|
|
|
|
|
|
erklaerung = JARGON_EVERYDAY.sub(" ", erklaerung.lower())
|
2026-09-17 11:42:58 +02:00
|
|
|
|
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_])")
|
2026-09-17 16:50:27 +02:00
|
|
|
|
# Teilfragen-Marker am Zeilenanfang: "a)", "- b)", "**Teil 2:**", "**Teil 2:** Text", "1." – bewusst eng, um
|
2026-09-17 16:38:45 +02:00
|
|
|
|
# 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.
|
2026-09-17 16:50:27 +02:00
|
|
|
|
QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\**\s+\S",
|
2026-09-17 11:42:58 +02:00
|
|
|
|
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.
|
2026-09-17 16:38:45 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-09-17 11:42:58 +02:00
|
|
|
|
"""
|
2026-09-17 16:38:45 +02:00
|
|
|
|
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:
|
2026-09-17 11:42:58 +02:00
|
|
|
|
roh = roh.replace(".", "").replace(",", ".")
|
|
|
|
|
|
elif "." in roh:
|
|
|
|
|
|
teile = roh.split(".")
|
|
|
|
|
|
if all(len(t) == 3 for t in teile[1:]) and len(teile[0]) <= 3:
|
|
|
|
|
|
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()
|
|
|
|
|
|
for roh in NUMBER_PATTERN.findall(text or ""):
|
|
|
|
|
|
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."""
|
|
|
|
|
|
return {(a or b or c).lower() for a, b, c in QUESTION_PATTERN.findall(text or "")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 18:01:37 +02:00
|
|
|
|
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
|
2026-09-17 17:52:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-17 18:01:37 +02:00
|
|
|
|
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
|
2026-09-17 18:19:45 +02:00
|
|
|
|
fälschlich an: eine Lesung zählte zusätzlich „Knoten: 1, 2, 3, 4“ auf (tasks/wegenetz_mit_abbildung.md, 17.09.2026).
|
2026-09-17 18:01:37 +02:00
|
|
|
|
Eine vertauschte Pfeilrichtung (2 → 3 statt 3 → 2) fällt auf; eine in BEIDEN Lesungen gleich falsche nicht.
|
2026-09-17 17:52:52 +02:00
|
|
|
|
"""
|
2026-09-17 18:09:52 +02:00
|
|
|
|
from understanding.figures import canonical_figure_id
|
2026-09-17 17:52:52 +02:00
|
|
|
|
issues = []
|
2026-09-17 18:09:52 +02:00
|
|
|
|
erste = {canonical_figure_id(f.id): f for f in first}
|
|
|
|
|
|
zweite = {canonical_figure_id(f.id): f for f in second}
|
2026-09-17 17:52:52 +02:00
|
|
|
|
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
|
2026-09-17 18:01:37 +02:00
|
|
|
|
a = _number_rows(erste[kennung].transcription)
|
|
|
|
|
|
b = _number_rows(zweite[kennung].transcription)
|
2026-09-17 17:52:52 +02:00
|
|
|
|
if a != b:
|
2026-09-17 18:01:37 +02:00
|
|
|
|
zeilen = lambda z: ", ".join("(" + " ".join(t) + ")" for t in sorted(z.elements())) or "–"
|
2026-09-17 17:52:52 +02:00
|
|
|
|
issues.append(CheckIssue(
|
|
|
|
|
|
severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
|
2026-09-17 18:01:37 +02:00
|
|
|
|
message=f"{kennung} uneinheitlich gelesen – Datenzeilen nur in Lesung 1: {zeilen(a - b)}; "
|
|
|
|
|
|
f"nur in Lesung 2: {zeilen(b - a)}."))
|
2026-09-17 17:52:52 +02:00
|
|
|
|
return issues
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 11:42:58 +02:00
|
|
|
|
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},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 name in 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)
|