diff --git a/checks/model_checks.py b/checks/model_checks.py index 7e48741..5cdf171 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -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) NUMBER_PATTERN = re.compile(r"(?]*\**\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) diff --git a/modeling/Routing-und-Extraktions-Prompt.txt b/modeling/Routing-und-Extraktions-Prompt.txt index 9063cf2..9d98ac6 100644 --- a/modeling/Routing-und-Extraktions-Prompt.txt +++ b/modeling/Routing-und-Extraktions-Prompt.txt @@ -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] - 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. - - 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. diff --git a/solving/solver_stage.py b/solving/solver_stage.py index b73bab8..89850ed 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -432,8 +432,18 @@ def check_constraint_coverage(constraints: list, spec: OptimizationModelSpecific identifiers = [c.identifier for c in spec.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): - 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 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 # ("Tausend Euro" deckt "€", "Stunden" deckt "min"). Prozent fehlt bewusst – es ist keine Einheit, die # 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 = { - "Geld": ("€", "EUR", "Euro", "$", "USD", "Dollar", "CHF", "Cent", "ct", "GE", "Geldeinheit", "Geldeinheiten"), - "Menge": ("Stück", "Stk", "ME", "Mengeneinheit", "Mengeneinheiten"), + "Geld": ("€", "EUR", "Euro", "Euros", "euros", "$", "USD", "Dollar", "Dollars", "dollars", "CHF", "Cent", "Cents", + "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", - "Woche", "Wochen", "Monat", "Monate", "Monaten", "Jahr", "Jahre", "Jahren"), - "Masse": ("g", "kg", "t", "Tonne", "Tonnen"), - "Länge": ("mm", "cm", "m", "km", "Meter", "Kilometer"), - "Volumen": ("l", "Liter", "hl", "m³"), + "Woche", "Wochen", "Monat", "Monate", "Monaten", "Jahr", "Jahre", "Jahren", + "hour", "hours", "minute", "minutes", "seconds", "day", "days", "week", "weeks", "month", "months", + "year", "years"), + "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_ALT = "|".join(sorted(map(re.escape, _UNIT_OF), key=len, reverse=True)) diff --git a/tests/test_feasibility.py b/tests/test_feasibility.py index 45df276..62086a8 100644 --- a/tests/test_feasibility.py +++ b/tests/test_feasibility.py @@ -149,3 +149,24 @@ def test_relaxation_bound_equal_up_to_rounding_is_no_warning(): # beobachtet (Portfolio Nr. 2): 0.13896862211718014 gegen 0.13896862211718017 run = sens_run([], objective=0.13896862211718017, relaxation=0.13896862211718014) 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"] diff --git a/tests/test_model_checks.py b/tests/test_model_checks.py index d3b3cd6..6036fcd 100644 --- a/tests/test_model_checks.py +++ b/tests/test_model_checks.py @@ -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"): assert QUESTION_PATTERN.search(zeile), zeile 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 diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 09daeb9..72af4e3 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -337,3 +337,11 @@ def test_marks_without_dijkstra_steps_are_still_flagged(): def test_everyday_marked_is_no_jargon(): from checks.model_checks import unexplained_jargon 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