diff --git a/checks/model_checks.py b/checks/model_checks.py index a30ad4c..7e48741 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -78,9 +78,15 @@ JARGON = { } +# 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, erklaerung = sichtbar.lower(), erklaerung.lower() + 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)) @@ -104,8 +110,9 @@ 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)\s+(\d+)|([1-9])\.)\s*[:.)]?\s+\S", +# 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])\)|(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\s+\S", re.MULTILINE) @@ -115,8 +122,16 @@ def normalize_number(roh: str) -> str: 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. """ - if "," in roh: + 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(".") diff --git a/modeling/Routing-und-Extraktions-Prompt.txt b/modeling/Routing-und-Extraktions-Prompt.txt index 8e3a667..9063cf2 100644 --- a/modeling/Routing-und-Extraktions-Prompt.txt +++ b/modeling/Routing-und-Extraktions-Prompt.txt @@ -38,6 +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] 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-Prompt.txt b/solving/Solver-Prompt.txt index 402e3d6..3df910d 100644 --- a/solving/Solver-Prompt.txt +++ b/solving/Solver-Prompt.txt @@ -30,7 +30,7 @@ API je target_stack: Startlösung (Warmstart): `sol = model.createSol(); model.setSolVal(sol, var, wert)` für jede Variable, dann `model.addSol(sol)` vor `model.optimize()`. - NetworkX (Dijkstra): `import networkx as nx`; `G = nx.DiGraph()`; Bögen mit `G.add_edge(i, j, weight=c)`; `laenge, pfad = nx.single_source_dijkstra(G, quelle, ziel, weight="weight")` liefert Weglänge und Knotenfolge; alle endgültigen Marken in der Reihenfolge ihrer Vergabe: `marken = nx.single_source_dijkstra_path_length(G, quelle, weight="weight")` (das Wörterbuch ist in genau dieser Reihenfolge gefüllt). Ist das Ziel nicht erreichbar, wirft `single_source_dijkstra` `nx.NetworkXNoPath` – dann Status "infeasible" melden. WICHTIG: Bei negativen Bogengewichten wirft networkx KEINEN Fehler, liefert aber ein falsches Ergebnis (geprüft: 3 statt 2). Prüfe die Gewichte im Programm und melde Status "error" mit einem Hinweis, falls eines negativ ist. - Melde den Graphen im Argument graph (siehe unten) – ohne ihn kann der Report die verlangte Zeichnung nicht erzeugen. Die Restriktionen der Spezifikation (Flusserhaltung) sind trotzdem in constraints auszuweisen, ausgewertet am gefundenen Weg. + Melde den Graphen im Argument graph (siehe unten) – ohne ihn kann der Report die verlangte Zeichnung nicht erzeugen. Die Restriktionen der Spezifikation (Start, Ende und Weiterführung an jedem Zwischenknoten) sind trotzdem in constraints auszuweisen, ausgewertet am gefundenen Weg. - Ipopt (cyipopt): `from cyipopt import minimize_ipopt`; `res = minimize_ipopt(zielfunktion, x0=startwerte, bounds=[(lo, hi), ...], constraints=[{"type": "ineq"|"eq", "fun": g}], options={"print_level": 0, "sb": "yes"})`; minimiert immer (bei Maximierung Vorzeichen drehen und Zielwert zurückrechnen); `g(x) >= 0` bei "ineq", `g(x) == 0` bei "eq"; Erfolg `res.success`, Werte `res.x`, Zielwert `res.fun`. Ipopt findet nur LOKALE Optima: Starte von mindestens 5 verschiedenen, zulässigen Startpunkten und behalte das beste Ergebnis. Melde status "optimal" nur, wenn das Problem nachweislich konvex ist (dann ist lokal = global), sonst "feasible" und erwähne in note bzw. headline, dass es sich um die beste gefundene lokale Lösung handelt. Findet kein Startpunkt eine Lösung, melde "infeasible" bzw. "error". Berichte die Streuung über die Startpunkte im Argument multistart (siehe unten) – sie ist der einzige Hinweis darauf, ob das Optimum stabil ist. Verwende feste Startpunkte oder einen festen Zufallsstartwert (z. B. `np.random.default_rng(0)`), damit derselbe Lauf dasselbe Ergebnis liefert. diff --git a/tests/test_model_checks.py b/tests/test_model_checks.py index d60e0ae..d3b3cd6 100644 --- a/tests/test_model_checks.py +++ b/tests/test_model_checks.py @@ -446,3 +446,26 @@ def test_objective_label_is_still_stripped(): from checks.model_checks import _objective_without_label assert _objective_without_label("Z = 12 x").strip() == "12 x" assert _objective_without_label("\\sum_{i=0}^{2} c_i x_i").startswith("\\sum") + + +def test_everyday_words_neither_trigger_nor_explain_jargon(): + from checks.model_checks import unexplained_jargon + # beobachtet: „beeinflusst“ in der Zusammenfassung galt als Erklärung von „Flusserhaltung“ + assert unexplained_jargon("Flusserhaltung", "Die Bestellmenge beeinflusst die Lagerkosten.") == ["Fluss"] + assert unexplained_jargon("Der Einfluss des Preises", "Zusammenfassung") == [] + assert unexplained_jargon("Flusserhaltung", "Der Fluss durch einen Knoten bleibt erhalten.") == [] + + +def test_unambiguous_english_numbers_are_read_correctly(): + from checks.model_checks import normalize_number + assert normalize_number("1,234.56") == "1234.56" # vorher 1.23456 + assert normalize_number("12,345,678") == "12345678" + assert normalize_number("1,500") == "1.5" # mehrdeutig: bleibt deutsch gelesen + assert normalize_number("1.234,56") == "1234.56" # deutsch unverändert + + +def test_english_sub_question_markers_are_recognised(): + from checks.model_checks import QUESTION_PATTERN + 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