fix: Index-Platzhalter im Namensvertrag, englische Einheiten, fett gesetzte Teilfragen

- Routing-Prompt: Restriktionsnamen als Muster beschrieben statt kopierbarer Beispiele,
  kein Indexbuchstabe im identifier (Weiter_ab_Woche_k löste in 3/3 Läufen eine
  unnötige Solver-Reparatur aus)
- check_constraint_coverage: identifier …_k passt zu …_2, nur ohne direkten Treffer
  (nonneg_x/nonneg_y bleiben getrennt)
- check_answer_units: englische Einheitenwörter (tons, hours, euros …); vorher strich
  eine Reparatur die richtige Einheit aus einer Antwort auf eine englische Aufgabe
- QUESTION_PATTERN erkennt **Teil 1:** Text

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 16:50:27 +02:00
commit 915c79c1c8
6 changed files with 61 additions and 10 deletions

View file

@ -109,10 +109,10 @@ def check_jargon(spec: OptimizationModelSpecification) -> List[CheckIssue]:
# Zahl im Fließtext: 1.234,56 / 0,05 / 12 / 3.8 – ohne Zahlen, die Teil eines Wortes sind (A02, x1) # 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_])") NUMBER_PATTERN = re.compile(r"(?<![A-Za-z0-9_])\d+(?:[.,]\d+)*(?![A-Za-z0-9_])")
# Teilfragen-Marker am Zeilenanfang: "a)", "- b)", "**Teil 2:**", "1." – bewusst eng, um # 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") # 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. # gehören dazu: sonst fehlt bei englischen Aufgaben das Nachfassen bei übersehenen Teilfragen.
QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\s+\S", QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\**\s+\S",
re.MULTILINE) re.MULTILINE)

View file

@ -38,7 +38,7 @@ Entscheide streng nach folgenden Routing-Regeln (die Zuordnung Klasse → Stack
- Verlangt die Aufgabe ausdrücklich ein bestimmtes Graphverfahren (typisch: „Lösen Sie mit dem Dijkstra-Algorithmus“), ist dieser Stack zu wählen – ein gleichwertiges Flussmodell erfüllt die Aufgabenstellung nicht. [→ REQUESTED_METHOD_IGNORED] - Verlangt die Aufgabe ausdrücklich ein bestimmtes Graphverfahren (typisch: „Lösen Sie mit dem Dijkstra-Algorithmus“), ist dieser Stack zu wählen – ein gleichwertiges Flussmodell erfüllt die Aufgabenstellung nicht. [→ REQUESTED_METHOD_IGNORED]
- VORAUSSETZUNG: alle Bogengewichte sind nicht negativ. Gibt es negative Gewichte (Erlöse, Gutschriften), ist Dijkstra NICHT anwendbar – dann Flussmodell als MILP über HiGHS wählen und das in routing_rationale begründen. [→ check_graph] - VORAUSSETZUNG: alle Bogengewichte sind nicht negativ. Gibt es negative Gewichte (Erlöse, Gutschriften), ist Dijkstra NICHT anwendbar – dann Flussmodell als MILP über HiGHS wählen und das in routing_rationale begründen. [→ check_graph]
- Die Entscheidungsvariablen sind die Bögen des Weges; deklariere sie als binäre \(x_{ij}\), auch wenn der Algorithmus selbst ohne sie auskommt. - Die Entscheidungsvariablen sind die Bögen des Weges; deklariere sie als binäre \(x_{ij}\), auch wenn der Algorithmus selbst ohne sie auskommt.
- Benenne die Restriktionen in der Sprache der Aufgabe, nicht mit Netzwerk-Fachwörtern: z. B. „Start_Woche_1“ (der Plan beginnt in Woche 1), „Weiter_ab_Woche_k“ (wer in Woche k ankommt, bestellt dort erneut), „Ende_nach_Woche_4“ – nicht „Flusserhaltung“, „Quellfluss“, „Senke“. Diese Namen stehen im Report vor dem Leser. [→ UNEXPLAINED_JARGON] - Benenne die Restriktionen mit den Begriffen der Aufgabe (Zeitpunkte, Orte, Handlungen), nicht mit Netzwerk-Fachwörtern wie „Flusserhaltung“, „Quellfluss“, „Senke“ – diese Namen stehen im Report vor dem Leser. Muster, sinngemäß auf die Aufgabe übertragen: ein Name für den Anfang des Plans, einer für das Ende, einer für das Weitermachen an jedem Zwischenpunkt (etwa „Planbeginn“, „Planende“, „Anschluss_Zwischenzeitpunkt“). Der identifier einer indizierten Restriktion enthält KEINEN Indexbuchstaben (nicht „…_k“); das Programm hängt den Index selbst an. [→ UNEXPLAINED_JARGON, check_constraint_coverage]
GRÖSSENORDNUNG: Schätze aus dem Produkt der Indexmengen, wie viele Variablen das Modell bekommt, und nenne die Zahl in routing_rationale. Praktische Grenzen: LP ~10.000 Variablen, MILP/CP ~100–1.000 diskrete (NP-schwer), QP/SOCP ~1.000 stetige, MIQP/NLP ~100; kürzeste Wege sind auch bei \(10^5\) Knoten unkritisch. Über der Grenze bleibt der fachlich richtige Stack – die Folge („Optimalität im Zeitlimit voraussichtlich nicht beweisbar“) gehört als Annahme ausgewiesen, nicht das Modell vereinfacht. GRÖSSENORDNUNG: Schätze aus dem Produkt der Indexmengen, wie viele Variablen das Modell bekommt, und nenne die Zahl in routing_rationale. Praktische Grenzen: LP ~10.000 Variablen, MILP/CP ~100–1.000 diskrete (NP-schwer), QP/SOCP ~1.000 stetige, MIQP/NLP ~100; kürzeste Wege sind auch bei \(10^5\) Knoten unkritisch. Über der Grenze bleibt der fachlich richtige Stack – die Folge („Optimalität im Zeitlimit voraussichtlich nicht beweisbar“) gehört als Annahme ausgewiesen, nicht das Modell vereinfacht.

View file

@ -432,8 +432,18 @@ def check_constraint_coverage(constraints: list, spec: OptimizationModelSpecific
identifiers = [c.identifier for c in spec.constraints] identifiers = [c.identifier for c in spec.constraints]
reported = [c.name for c in constraints] reported = [c.name for c in constraints]
def direkt(name: str, identifier: str) -> bool:
return name == identifier or name.startswith(identifier + "_") or name.startswith(identifier + "[")
def platzhalter(name: str, identifier: str) -> bool:
# Index-Platzhalter im identifier („Weiter_ab_Woche_k“) gegen den ausgeschriebenen Index („…_2“) –
# beobachtet in allen drei Läufen 20260917-164603, jedes Mal eine unnötige Solver-Reparatur.
# Nur ohne direkten Treffer: „nonneg_x“/„nonneg_y“ sind echte Namen, kein Platzhalter.
basis = re.fullmatch(r"(.+)_[a-z]", identifier)
return bool(basis) and re.fullmatch(re.escape(basis.group(1)) + r"_[\w\[\]]+", name) is not None
def zuordnung(name: str): def zuordnung(name: str):
treffer = [i for i in identifiers if name == i or name.startswith(i + "_") or name.startswith(i + "[")] treffer = [i for i in identifiers if direkt(name, i)] or [i for i in identifiers if platzhalter(name, i)]
return max(treffer, key=len) if treffer else None return max(treffer, key=len) if treffer else None
zugeordnet = {name: zuordnung(name) for name in reported} zugeordnet = {name: zuordnung(name) for name in reported}
@ -884,14 +894,19 @@ def check_answers(run, understanding, task: str = "", data_summary: str = "") ->
# Einheiten nach Größenart: gedeckt ist eine Einheit, wenn der Text irgendeine derselben Art nennt # Einheiten nach Größenart: gedeckt ist eine Einheit, wenn der Text irgendeine derselben Art nennt
# ("Tausend Euro" deckt "€", "Stunden" deckt "min"). Prozent fehlt bewusst – es ist keine Einheit, die # ("Tausend Euro" deckt "€", "Stunden" deckt "min"). Prozent fehlt bewusst – es ist keine Einheit, die
# man erfinden kann, sondern eine Schreibweise für Anteile. # man erfinden kann, sondern eine Schreibweise für Anteile.
# Englische Wörter gehören dazu: beobachtet (lp_klein auf Englisch) galt „1 Tonne“ als erfunden, weil die
# Aufgabe „tons“ schreibt – eine Reparatur strich daraufhin die richtige Einheit aus der Antwort.
UNIT_FAMILIES = { UNIT_FAMILIES = {
"Geld": ("€", "EUR", "Euro", "$", "USD", "Dollar", "CHF", "Cent", "ct", "GE", "Geldeinheit", "Geldeinheiten"), "Geld": ("€", "EUR", "Euro", "Euros", "euros", "$", "USD", "Dollar", "Dollars", "dollars", "CHF", "Cent", "Cents",
"Menge": ("Stück", "Stk", "ME", "Mengeneinheit", "Mengeneinheiten"), "cents", "ct", "GE", "Geldeinheit", "Geldeinheiten"),
"Menge": ("Stück", "Stk", "ME", "Mengeneinheit", "Mengeneinheiten", "pieces", "pcs"),
"Zeit": ("h", "Std", "Stunde", "Stunden", "min", "Minute", "Minuten", "s", "Sekunden", "Tag", "Tage", "Tagen", "Zeit": ("h", "Std", "Stunde", "Stunden", "min", "Minute", "Minuten", "s", "Sekunden", "Tag", "Tage", "Tagen",
"Woche", "Wochen", "Monat", "Monate", "Monaten", "Jahr", "Jahre", "Jahren"), "Woche", "Wochen", "Monat", "Monate", "Monaten", "Jahr", "Jahre", "Jahren",
"Masse": ("g", "kg", "t", "Tonne", "Tonnen"), "hour", "hours", "minute", "minutes", "seconds", "day", "days", "week", "weeks", "month", "months",
"Länge": ("mm", "cm", "m", "km", "Meter", "Kilometer"), "year", "years"),
"Volumen": ("l", "Liter", "hl", "m³"), "Masse": ("g", "kg", "t", "Tonne", "Tonnen", "ton", "tons", "tonne", "tonnes", "kilogram", "kilograms"),
"Länge": ("mm", "cm", "m", "km", "Meter", "Kilometer", "meter", "meters", "metre", "metres", "kilometers"),
"Volumen": ("l", "Liter", "hl", "m³", "liter", "liters", "litre", "litres"),
} }
_UNIT_OF = {einheit: art for art, einheiten in UNIT_FAMILIES.items() for einheit in einheiten} _UNIT_OF = {einheit: art for art, einheiten in UNIT_FAMILIES.items() for einheit in einheiten}
_UNIT_ALT = "|".join(sorted(map(re.escape, _UNIT_OF), key=len, reverse=True)) _UNIT_ALT = "|".join(sorted(map(re.escape, _UNIT_OF), key=len, reverse=True))

View file

@ -149,3 +149,24 @@ def test_relaxation_bound_equal_up_to_rounding_is_no_warning():
# beobachtet (Portfolio Nr. 2): 0.13896862211718014 gegen 0.13896862211718017 # beobachtet (Portfolio Nr. 2): 0.13896862211718014 gegen 0.13896862211718017
run = sens_run([], objective=0.13896862211718017, relaxation=0.13896862211718014) run = sens_run([], objective=0.13896862211718017, relaxation=0.13896862211718014)
assert check_sensitivity(run, "maximize") == [] assert check_sensitivity(run, "maximize") == []
def test_index_placeholder_in_identifier_matches_the_written_out_index():
# beobachtet (20260917-164603): Spezifikation „Weiter_ab_Woche_k“, Programm „Weiter_ab_Woche_2“ – je eine Reparatur
from test_model_checks import make_spec
spec = make_spec(constraints=[
{"identifier": "Start_Woche_1", "latex_formula": "x = 1", "description": "-", "is_hard_constraint": True},
{"identifier": "Weiter_ab_Woche_k", "latex_formula": "x = y", "description": "-", "is_hard_constraint": True}])
assert check_constraint_coverage([Named("Start_Woche_1"), Named("Weiter_ab_Woche_2"), Named("Weiter_ab_Woche_3")], spec) == []
# ein anderer Name mit gleichem Anfang bleibt ein Befund
assert check_constraint_coverage([Named("Start_Woche_1"), Named("Weiter_ab_Monat_2")], spec)
def test_real_names_ending_in_a_letter_are_not_placeholders():
# Rückrechnung hard_transport_lagerwahl: nonneg_x und nonneg_y sind zwei echte Restriktionen
from test_model_checks import make_spec
spec = make_spec(constraints=[
{"identifier": "nonneg_x", "latex_formula": "x >= 0", "description": "-", "is_hard_constraint": True},
{"identifier": "nonneg_y", "latex_formula": "y >= 0", "description": "-", "is_hard_constraint": True}])
assert check_constraint_coverage([Named("nonneg_x"), Named("nonneg_y")], spec) == []
assert check_constraint_coverage([Named("nonneg_x")], spec) == ["Restriktion 'nonneg_y' aus der Spezifikation fehlt im Programm"]

View file

@ -469,3 +469,10 @@ def test_english_sub_question_markers_are_recognised():
for zeile in ("Part 2: Solve the model", "* **Question 1: How many units**", "- Task 3: Compare"): for zeile in ("Part 2: Solve the model", "* **Question 1: How many units**", "- Task 3: Compare"):
assert QUESTION_PATTERN.search(zeile), zeile assert QUESTION_PATTERN.search(zeile), zeile
assert not QUESTION_PATTERN.search("In part 2 of the text we see") # Fließtext bleibt Fließtext assert not QUESTION_PATTERN.search("In part 2 of the text we see") # Fließtext bleibt Fließtext
def test_bold_marker_with_colon_inside_is_recognised():
# vorher nur "**Teil 1: Daten**", nicht "**Teil 1:** Daten" (Doppelpunkt vor dem schließenden **)
from checks.model_checks import QUESTION_PATTERN
for zeile in ("**Teil 1:** Daten laden", "* **Question 2:** Solve", "**a)** Graph zeichnen"):
assert QUESTION_PATTERN.search(zeile), zeile

View file

@ -337,3 +337,11 @@ def test_marks_without_dijkstra_steps_are_still_flagged():
def test_everyday_marked_is_no_jargon(): def test_everyday_marked_is_no_jargon():
from checks.model_checks import unexplained_jargon from checks.model_checks import unexplained_jargon
assert unexplained_jargon("nur die mit 'ja' markierten Kombinationen gehören zum Plan", "Zusammenfassung") == [] assert unexplained_jargon("nur die mit 'ja' markierten Kombinationen gehören zum Plan", "Zusammenfassung") == []
def test_english_unit_words_cover_german_answers():
# beobachtet: lp_klein auf Englisch – „1 Tonne“ galt als erfunden, weil die Aufgabe „tons“ schreibt
from solving.solver_stage import check_answer_units
aufgabe = "One ton of raw material X costs 3 thousand euros; at least 4 tons are required."
assert check_answer_units(_run_with_answer("Es sollten 1 Tonne X und 3 Tonnen Y gekauft werden, Kosten 9 Tausend Euro."), aufgabe) == []
assert check_answer_units(_run_with_answer("It takes 12 hours."), "Minimize the cost.") != [] # erfunden bleibt erfunden