From 7aac3ff3ec8d9787ea416323a3e9549cdcbdbe7e Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 13:34:54 +0200 Subject: [PATCH 1/4] fix: Vergleichstabellen nicht als Summenabweichung melden; Verstehen-Prompt in Prompt-Kennung Co-Authored-By: Claude Opus 5 --- benchmark/benchmark.py | 5 +++-- solving/solver_stage.py | 13 +++++++++++++ tests/test_pipeline.py | 1 + tests/test_result_tables.py | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 1b783b6..5a77f8c 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -26,9 +26,10 @@ RESULTS_DIR = BENCHMARK_DIR / "results" # Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten quota_exhausted = threading.Event() -# Dateien, die das LLM-Verhalten steuern: die drei System-Prompts und die Feldbeschreibungen +# Dateien, die das LLM-Verhalten steuern: die vier System-Prompts und die Feldbeschreibungen # (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung). -PROMPT_FILES = ("modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", "solving/Solver-Prompt.txt", +PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", + "solving/Solver-Prompt.txt", "core/model_schema.py") # Umgebungsvariablen, die das Verhalten ändern – ohne sie ließe sich ein A/B-Lauf nicht zuordnen diff --git a/solving/solver_stage.py b/solving/solver_stage.py index c0a4f2a..59e2322 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -271,6 +271,17 @@ def _is_sum_row(row, value=None, others=None) -> bool: return _close(value, sum(others)) +def _is_comparison(values, no_sum_row: bool, total: float, objective_value) -> bool: + """Vergleichstabelle: jede Zeile ist ein eigener Zielwert (Konfiguration, Szenario), keine Zerlegung. + + Erkennbar daran, dass die Summe nicht passt, aber genau eine Zeile den gemeldeten Zielwert trägt. + Beobachtet: "Vergleich der Konfigurationen" (Portfolio Nr. 2) galt sonst als "Summe weicht ab". + """ + if objective_value is None or not no_sum_row or len(values) < 2 or _close(total, objective_value): + return False + return sum(_close(v, objective_value) for v in values) == 1 + + def _close(a: float, b: float) -> bool: return abs(a - b) <= 1e-3 * max(abs(a), abs(b)) + 1e-6 @@ -303,6 +314,8 @@ def parse_result_tables(raw_tables: list, objective_value) -> tuple: warnings.append(f"Tabelle '{table.title}': Spalte '{table.objective_column}' enthält nicht nur Zahlen – Summe nicht prüfbar.") continue total = sum(values) + if _is_comparison(values, len(values) == len(cells), total, objective_value): + continue if objective_value is not None and not _close(total, objective_value): warnings.append( f"Tabelle '{table.title}': Summe der Spalte '{table.objective_column}' ({total:.6g}) " diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index ab3a431..ee8339d 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -309,6 +309,7 @@ def test_benchmark_records_the_prompt_state(): assert len(stand["prompts"]) == 12 and stand["commit"] assert "modeling/Routing-und-Extraktions-Prompt.txt" in PROMPT_FILES assert "core/model_schema.py" in PROMPT_FILES # Feldbeschreibungen wirken wie Prompt-Anweisungen + assert "understanding/Verstehen-Prompt.txt" in PROMPT_FILES def test_deviating_settings_are_recorded(monkeypatch): diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 0551d7d..9f6cc3a 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -228,3 +228,26 @@ def test_ascii_form_present_in_the_task_is_not_replaced(): zuordnung = umlaut_map("Herr Müller und die Firma Mueller liefern.") assert "mueller" not in zuordnung assert fix_transliterations("Firma Mueller", zuordnung) == "Firma Mueller" + + +# beobachtet in Portfolio Nr. 2: eine Zeile je Konfiguration, die gewählte trägt den Zielwert +COMPARISON = { + "title": "Vergleich der Konfigurationen", + "columns": ["Konfiguration", "lambda1", "lambda2", "Zielfunktionswert"], + "rows": [["Reine Varianzminimierung", "1.0", "0.0", 0.18217355], + ["Reine Korrelationsminimierung", "0.0", "1.0", -187.29514112], + ["Ausgewogene Skalarisierung", "0.5", "0.5", -93.56364364]], + "objective_column": "Zielfunktionswert", +} + + +def test_comparison_table_is_not_a_sum_mismatch(): + tables, warnings = parse_result_tables([COMPARISON], objective_value=0.18217353985482126) + assert warnings == [] and len(tables) == 1 + + +def test_decomposition_with_one_term_equal_to_objective_is_still_checked(): + # eine Summenzeile macht daraus eine Zerlegung – deren Fehler bleibt sichtbar + table = dict(DECOMPOSITION, rows=[["Rendite", 0.097557], ["Kosten", -0.05], ["Zielwert", 0.2]]) + _, warnings = parse_result_tables([table], objective_value=0.097557) + assert any("weicht vom Zielwert" in w for w in warnings) From c68a2937554457ce7270ed54dcbf2b272ddcc133 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 14:07:57 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Erfundene=20Einheiten=20in=20Antwor?= =?UTF-8?q?ten=20pr=C3=BCfen;=20data=5Fassumptions-Kanal=20klargestellt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_answer_units meldet Einheiten hinter Zahlen, die weder Aufgabentext noch Datenprofil hergeben (nach Größenart, GE/ME neutral bei Kosten-/Mengenwörtern); Befund löst Solver-Reparatur aus, bleibt er, Warnung. Reparaturmeldung und Solver-Prompt nennen data_assumptions jetzt als Antwortfeld, nicht report()-Argument. Co-Authored-By: Claude Opus 5 --- solving/Solver-Prompt.txt | 3 +- solving/solver_stage.py | 65 +++++++++++++++++++++++++++++++++++-- tests/test_pipeline.py | 2 +- tests/test_result_tables.py | 42 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/solving/Solver-Prompt.txt b/solving/Solver-Prompt.txt index d0194f4..7e06819 100644 --- a/solving/Solver-Prompt.txt +++ b/solving/Solver-Prompt.txt @@ -11,7 +11,7 @@ Regeln für das Modell: - Implementiere Variablen, Zielfunktion und Restriktionen exakt wie in der Spezifikation. Lasse keine Restriktion weg, lockere keine und füge keine hinzu. - Ist das Modell unlösbar oder unbeschränkt, melde genau das (Status infeasible bzw. unbounded). Verändere das Modell nicht, um eine Lösung zu erzwingen. - Daten: Verwende die Werte aus extracted_values bzw. dem Aufgabentext. Fehlen Werte, wähle kleine, plausible Beispieldaten und führe JEDEN angenommenen Wert in data_assumptions auf (Symbol, Wert, Begründung). Stehen alle Werte im Text, bleibt data_assumptions leer. -- Erzeugst du Eingabedaten selbst – Zufallszahlen, ein synthetischer Markt, ein verkleinertes Universum, weil die echten Daten nicht erreichbar sind –, gehört das IMMER nach data_assumptions, nicht nur in eine Tabelle oder einen Antwortsatz. Sonst ist dem Ergebnis maschinell nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht. [→ check_generated_data] +- Erzeugst du Eingabedaten selbst – Zufallszahlen, ein synthetischer Markt, ein verkleinertes Universum, weil die echten Daten nicht erreichbar sind –, gehört das IMMER nach data_assumptions (Feld deiner Antwort neben code, kein Argument von report()), nicht nur in eine Tabelle oder einen Antwortsatz. Sonst ist dem Ergebnis maschinell nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht. [→ check_generated_data] - Die offenen Annahmen (open_assumptions) der Spezifikation gelten und sind umzusetzen. Erlaubte Imports (andere sind verboten und führen zum Abbruch): json, math, itertools, functools, collections, numpy, scipy, highspy, ortools, cvxpy, pyscipopt, cyipopt, networkx, pandas. @@ -85,6 +85,7 @@ Ergebnisausgabe: - Auch bei Status infeasible oder unbounded wird JEDE Teilfrage beantwortet – dann lautet die Antwort, dass und warum die Aufgabe keine zulässige Lösung hat (welche Bedingungen sich widersprechen). Eine unbeantwortete Frage ist schlechter als die Antwort „unlösbar“. - Teilfragen ohne Rechnung (formulieren, zeichnen, erklaeren) werden ebenfalls beantwortet – dort beschreibt der Text das aufgestellte Modell bzw. die Struktur der Zeichnung in Worten. - Verwende NUR Zahlen, die dieses Programm berechnet hat oder die im Aufgabentext stehen. [→ check_answers] + - Ebenso bei Einheiten: nur solche, die im Aufgabentext oder in beigelegten Daten stehen. Nennt der Text keine, steht die Zahl ohne Einheit („Der kleinste Zielwert ist 459.“); GE bzw. ME nur, wenn der Text von Kosten, Preisen bzw. Mengen spricht. [→ check_answer_units] - WICHTIG, häufigster Fehler: Sobald eine Antwort einen Vergleichs- oder Zwischenwert nennt, muss dieser Wert auch in einer Ergebnistabelle stehen. Beobachtet: „der beste Weg kostet 29, die Alternative 30“ – die 30 stand nirgends, der Leser konnte sie nicht prüfen. Richtig ist eine zusätzliche Tabelle („Alle Wege“, „Vergleich der Varianten“, „Kostenbestandteile“) mit genau diesen Zahlen. Ebenso für Summen wie „insgesamt 2,32 % Gebühren“: entweder eine Summenzeile in der Tabelle oder die Zahl weglassen. - Fehlt eine Teilfrage, ist die Aufgabe nicht erfüllt – auch wenn der Zielwert stimmt. [→ check_answers] - scenarios: NUR wenn die Aufgabe mehrere Konfigurationen vergleicht (z. B. drei Gewichtungen), sonst leer. Ein Szenario ist eine DATENVARIANTE desselben Modells, kein zweites Modell: diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 59e2322..9220824 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -743,7 +743,8 @@ def check_generated_data(code: str, solver_code, spec=None) -> list: and all(numbers_in(p.extracted_values or "") for p in spec.parameters)): return [] return ["Das Programm erzeugt Zahlen mit einem Zufallsgenerator, weist aber keine angenommenen " - "Daten aus. Selbst erzeugte Eingabedaten gehören in data_assumptions – sonst ist dem " + "Daten aus. Selbst erzeugte Eingabedaten gehören in data_assumptions – das Feld der Antwort " + "neben code, kein Argument von report() –, sonst ist dem " "Ergebnis nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht."] @@ -802,6 +803,57 @@ def check_answers(run, understanding, task: str = "", data_summary: str = "") -> return befunde +# 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. +UNIT_FAMILIES = { + "Geld": ("€", "EUR", "Euro", "$", "USD", "Dollar", "CHF", "Cent", "ct", "GE", "Geldeinheit", "Geldeinheiten"), + "Menge": ("Stück", "Stk", "ME", "Mengeneinheit", "Mengeneinheiten"), + "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³"), +} +_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)) +# Kurze Einheiten (h, s, t, kg) decken nur mit Zahl davor oder in Klammern – sonst deckt „s.t." oder x_t alles +_UNIT_COVER = re.compile(r"(? list: + """Nennt eine Antwort eine Einheit, die weder Aufgabentext noch beigelegte Daten hergeben? + + Beobachtet an `tasks/nur_formeln.md` (reines ILP ohne jede Einheit): „459 Geldeinheiten (GE)", + obwohl die eigene Annahme die Zahlen dimensionslos nannte; in der Urfassung der Ersatzplanung + („Kosten" ohne Währung) dreimal „29 €". Die Regel im Erklär-Block allein wirkte nicht. + Belegt ist nur, was im Text steht – `ParameterDefinition.unit` taugt nicht, dort erfand dasselbe + LLM „Stück" und „Mengeneinheit". An 146 gespeicherten Läufen: 4 Treffer, alle echt, kein Fehlalarm. + Der Größenfaktor („Tausend Euro" gegen „€") wird bewusst nicht geprüft. + """ + gedeckt = {_UNIT_OF[m.group(1)] for m in _UNIT_AFTER_NUMBER.finditer(task)} + gedeckt |= {_UNIT_OF[m.group(2)] for m in _UNIT_COVER.finditer(task) + if len(m.group(2)) > 2 or not m.group(2).isalpha() or m.group(1)} + # Datenprofil: Spaltennamen wie emission_kg tragen die Einheit ohne Zahl davor + gedeckt |= {_UNIT_OF[m.group(2)] for m in _UNIT_COVER.finditer(data_summary.replace("_", " "))} + befunde = [] + for antwort in run.answers: + erfunden = sorted({m.group(1) for m in _UNIT_AFTER_NUMBER.finditer(antwort.text or "") + if _UNIT_OF[m.group(1)] not in gedeckt + and not (m.group(1) in _NEUTRAL_UNITS and _NEUTRAL_UNITS[m.group(1)].search(task))}) + if erfunden: + befunde.append(f"Antwort zu '{antwort.question_id}' nennt die Einheit {', '.join(erfunden)}, die weder im " + f"Aufgabentext noch in beigelegten Daten steht. Nennt der Text keine Einheit, steht die " + f"Zahl ohne Einheit – erfinde keine.") + return befunde + + def check_graph(run, sense: str) -> list: """Graph und kürzesten Weg deterministisch nachrechnen (ohne LLM). @@ -877,17 +929,24 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, solver_code.code = clean_generated_code(solver_code.code) run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec) + einheiten = check_answer_units(run, task, data_prompt) attempt = 1 - while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data) and attempt <= retries: + while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or einheiten) \ + and attempt <= retries: fehler = run.error or (("Die Lösung hält das Modell nicht ein:\n- " + "\n- ".join(run.feasibility)) if run.feasibility else - ("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data))) + ("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data)) + if run.undeclared_data else + ("Einheit in der Antwort erfunden:\n- " + "\n- ".join(einheiten))) log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}") solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, understanding=understanding, data_prompt=data_prompt) solver_code.code = clean_generated_code(solver_code.code) run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec) + einheiten = check_answer_units(run, task, data_prompt) attempt += 1 + # Bleibt die erfundene Einheit, ist das Ergebnis trotzdem richtig – Warnung, nicht unzuverlässig + run.warnings += einheiten run.attempts = attempt return solver_code, run diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index ee8339d..416db06 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -422,7 +422,7 @@ if __name__ == "__main__": sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Modell!", kind="formulieren", needs_solver=False), SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)]) session = FakeSession([GOOD], [OK], [SolverCode(code=code)], understandings=[verstanden]) - result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0) markdown = to_markdown(build_document(result)) assert "Antworten auf die Aufgabe" in markdown assert markdown.index("Formulieren Sie das Modell!") < markdown.index("| Status |") diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 9f6cc3a..0dace43 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -251,3 +251,45 @@ def test_decomposition_with_one_term_equal_to_objective_is_still_checked(): table = dict(DECOMPOSITION, rows=[["Rendite", 0.097557], ["Kosten", -0.05], ["Zielwert", 0.2]]) _, warnings = parse_result_tables([table], objective_value=0.097557) assert any("weicht vom Zielwert" in w for w in warnings) + + +# --- Einheiten in Antworten: nur, was der Text hergibt --- + +def _run_with_answer(text): + from core.model_schema import Answer + return SolverRun(status=SolverStatus.OPTIMAL, objective_value=459, answers=[Answer(question_id="1", text=text)]) + + +def test_invented_unit_in_answer_is_found(): + # beobachtet an tasks/nur_formeln.md: reines ILP ohne jede Einheit + from solving.solver_stage import check_answer_units + befunde = check_answer_units(_run_with_answer("Der minimale Zielfunktionswert beträgt 459 Geldeinheiten (GE)."), + "min z = 17*x1 + 23*x2 s.t. 6*x1 + 4*x2 >= 120") + assert len(befunde) == 1 and "Geldeinheiten" in befunde[0] + + +def test_currency_is_invented_when_the_text_only_says_costs(): + from solving.solver_stage import check_answer_units + aufgabe = "Die Kostenmatrix C enthält die Kosten je Nutzungsdauer." + assert check_answer_units(_run_with_answer("Die Gesamtkosten betragen 29 €."), aufgabe) + assert check_answer_units(_run_with_answer("Die Gesamtkosten betragen 29 GE."), aufgabe) == [] # neutral + + +def test_unit_of_the_same_kind_in_the_text_is_covered(): + from solving.solver_stage import check_answer_units + aufgabe = "Kosten in Tausend Euro; Rüstzeit 2 Stunden." + run = _run_with_answer("Die Kosten betragen 29 Tsd. € bei 90 min Rüstzeit, Auslastung 85 %.") + assert check_answer_units(run, aufgabe) == [] + + +def test_unit_from_attached_data_is_covered(): + from solving.solver_stage import check_answer_units + run = _run_with_answer("Der Transport kostet 120 kg CO2.") + assert check_answer_units(run, "Minimiere die Emissionen.") != [] + assert check_answer_units(run, "Minimiere die Emissionen.", data_summary="Spalte emission_kg") == [] + + +def test_numbers_and_words_are_not_mistaken_for_units(): + from solving.solver_stage import check_answer_units + run = _run_with_answer("Teil 2 zeigt: x1 = 6, 3 Maschinen, Stufe 2a, am Tag 3 sind alle 5 Aufträge erledigt.") + assert check_answer_units(run, "Plane die Aufträge.") == [] From ee9a193cd7977c252f7feda1ff153c15419ead73 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 14:22:22 +0200 Subject: [PATCH 3/4] fix: Toleranz bei Relaxationsschranke, Grund bei fehlgeschlagenem Szenario Co-Authored-By: Claude Opus 5 --- solving/solver_stage.py | 12 +++++++++--- tests/test_feasibility.py | 6 ++++++ tests/test_solver_stage.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 9220824..7613717 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -396,7 +396,9 @@ def check_sensitivity(run, sense: str) -> list: Die Richtung hängt am Sinn der Restriktion und daran, ob maximiert oder minimiert wird. """ warnings = [] - besser = (lambda a, b: a > b) if sense == "maximize" else (lambda a, b: a < b) + # mit Toleranz: beobachtet Schranke 0.13896862211718014 gegen Lösung ...17 als „schlechter" gemeldet + besser = ((lambda a, b: a > b + _tolerance(b)) if sense == "maximize" + else (lambda a, b: a < b - _tolerance(b))) by_name = {c.name: c for c in run.constraints} for s in run.sensitivity: constraint = by_name.get(s.name) @@ -566,7 +568,9 @@ def check_multistart(run, sense: str) -> list: if m is None: return [] warnings = [] - besser = (lambda a, b: a > b) if sense == "maximize" else (lambda a, b: a < b) + # mit Toleranz: beobachtet Schranke 0.13896862211718014 gegen Lösung ...17 als „schlechter" gemeldet + besser = ((lambda a, b: a > b + _tolerance(b)) if sense == "maximize" + else (lambda a, b: a < b - _tolerance(b))) if m.converged > m.starts or m.converged < 0: warnings.append(f"Mehrstart: {m.converged} erfolgreiche von {m.starts} Startpunkten ist nicht möglich.") elif m.converged < 2: @@ -685,7 +689,9 @@ def verify_scenarios(run, code: str, timeout: int = 60, data_files=()) -> list: data_files=data_files) szenario.verified_value = nachlauf.objective_value if nachlauf.status == SolverStatus.ERROR: - warnings.append(f"Szenario '{szenario.name}' ließ sich nicht nachrechnen: {(nachlauf.error or '')[:120]}") + grund = (nachlauf.error or "").strip()[:120] or ( + "das Programm meldete selbst Status 'error' ohne Fehlertext (z. B. keine Lösung im Zeitlimit)") + warnings.append(f"Szenario '{szenario.name}' ließ sich nicht nachrechnen: {grund}") elif (szenario.objective_value is not None and nachlauf.objective_value is not None and abs(szenario.objective_value - nachlauf.objective_value) > 1e-6 * max(1.0, abs(szenario.objective_value))): diff --git a/tests/test_feasibility.py b/tests/test_feasibility.py index 81c7bfa..45df276 100644 --- a/tests/test_feasibility.py +++ b/tests/test_feasibility.py @@ -143,3 +143,9 @@ def test_report_shows_sensitivity_and_integrality_price(): page = to_html(build_document(result)) assert "Sensitivität der bindenden Restriktionen" in page and "Preis der Ganzzahligkeit" in page assert "0,1" in page.replace(".", ",") or "0.1" in page # (12100-12000)/1000 = 0.1 + + +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") == [] diff --git a/tests/test_solver_stage.py b/tests/test_solver_stage.py index 6c8d1c9..afc836f 100644 --- a/tests/test_solver_stage.py +++ b/tests/test_solver_stage.py @@ -753,3 +753,15 @@ def test_ordinary_programs_are_not_suspected(): from solving.solver_stage import check_generated_data code = "import highspy\nh = highspy.Highs()\nDATA = {'kosten': [3, 2]}\n" assert check_generated_data(code, SolverCode(code=code, data_assumptions=[])) == [] + + +def test_scenario_failing_without_error_text_gets_a_reason(): + # beobachtet (Portfolio Nr. 2): Szenario ohne Lösung im Zeitlimit, Warnung endete mit leerem Grund + from core.model_schema import Scenario + from solving.solver_stage import verify_scenarios + code = SZENARIO_CODE.replace(' basis = loesen(DATA)\n', + ' basis = loesen(DATA)\n if DATA["preis"] > 15:\n' + ' report("error", None, {})\n return\n') + run = run_solver_code(code) + befunde = verify_scenarios(run, code) + assert any("doppelter Preis" in b and "ohne Fehlertext" in b for b in befunde) From dd7206aee5e6a8b63d597c2ecda3e63117f98623 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 14:26:50 +0200 Subject: [PATCH 4/4] fix: Umlaut-Berichtigung erfasst Komposita und Beugungen, Bezeichner bleiben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wortstämme aus 252 gespeicherten Läufen, jeder gegen ngerman/american-english geprüft; Wörter mit _ oder Binnenmajuskel gelten als Bezeichner. Co-Authored-By: Claude Opus 5 --- solving/solver_stage.py | 33 ++++++++++++++++++++++++++------- tests/test_result_tables.py | 16 ++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 7613717..8804c29 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -604,8 +604,20 @@ UMLAUT_STEMS = { "jaehrl": "jährl", "kuerzest": "kürzest", "laenge": "länge", "staerk": "stärk", "hoeh": "höh", "fruehest": "frühest", "spaetest": "spätest", "baell": "bäll", "heisst": "heißt", "betraeg": "beträg", "ausfuehr": "ausführ", "abzueg": "abzüg", "enthaelt": "enthält", "tatsaechl": "tatsächl", + # Wortstämme aus 252 gespeicherten Läufen; greifen auch in Komposita („Gesamtstueckzahl") und Beugungen. + # Jeder Stamm ist gegen /usr/share/dict/ngerman und american-english geprüft: er steht in keinem + # korrekt geschriebenen Wort („loes" fiel heraus: aloes, floes). + "taet": "tät", "stueck": "stück", "fuehr": "führ", "traeg": "träg", "vollstaend": "vollständ", + "zulaess": "zuläss", "loesbar": "lösbar", "erloes": "erlös", "schoepf": "schöpf", "gebuehr": "gebühr", + "noetig": "nötig", "faell": "fäll", "haelt": "hält", "waehr": "währ", "waechs": "wächs", "waere": "wäre", + "laeuft": "läuft", "schluess": "schlüss", "duerf": "dürf", "kaeuf": "käuf", "wuerd": "würd", + "gueltig": "gültig", "gehoer": "gehör", "drueck": "drück", "uebrig": "übrig", "haeng": "häng", + "naemlich": "nämlich", "lueck": "lück", "saetz": "sätz", "plaen": "plän", "aermst": "ärmst", + "spaet": "spät", "frueh": "früh", "aender": "änder", "zaehl": "zähl", "verhaeltnis": "verhältnis", + "ergaenz": "ergänz", "begruend": "begründ", "pruef": "prüf", "rueck": "rück", "maessig": "mäßig", "vermoeg": "vermög", } _STEM_PATTERN = re.compile("|".join(UMLAUT_STEMS), re.IGNORECASE) +_CAMEL_CASE = re.compile(r"[a-zäöüß][A-ZÄÖÜ]") # KeinFruehNachSpaet _UMLAUT_WORD = re.compile(r"\b\w*[äöüÄÖÜß]\w*\b") @@ -635,17 +647,24 @@ def umlaut_map(aufgabe: str, *weitere: str) -> dict: def fix_transliterations(text: str, extra: dict | None = None) -> str: """'guenstigste' -> 'günstigste': erst Wörter aus dem Lauf (`umlaut_map`), dann die feste Stammliste. - Nur für Lesertexte. Bezeichner und Restriktionsnamen bleiben ASCII – der Namensabgleich hängt daran. + Nur für Lesertexte. Bezeichner und Restriktionsnamen bleiben ASCII – der Namensabgleich hängt daran; + auch in Tabellenzellen bleibt ein Wort mit `_` oder Binnenmajuskel unverändert (vorher wurde mal berichtigt, mal nicht). """ def gross_wie(vorlage, richtig): return richtig[:1].upper() + richtig[1:] if vorlage[:1].isupper() else richtig - text = text or "" - if extra: - muster = re.compile(r"\b(?:" + "|".join(sorted(map(re.escape, extra), key=len, reverse=True)) + r")\b", - re.IGNORECASE) - text = muster.sub(lambda t: gross_wie(t.group(0), extra[t.group(0).lower()]), text) - return _STEM_PATTERN.sub(lambda t: gross_wie(t.group(0), UMLAUT_STEMS[t.group(0).lower()]), text) + muster = (re.compile("^(?:" + "|".join(sorted(map(re.escape, extra), key=len, reverse=True)) + ")$", re.IGNORECASE) + if extra else None) + + def wort(t): + w = t.group(0) + if "_" in w or _CAMEL_CASE.search(w): # Bezeichner (Restriktionsnamen in Tabellenzellen) bleiben + return w + if muster and muster.match(w): + return gross_wie(w, extra[w.lower()]) + return _STEM_PATTERN.sub(lambda m: gross_wie(m.group(0), UMLAUT_STEMS[m.group(0).lower()]), w) + + return re.sub(r"\w+", wort, text or "") def check_text_quality(run, summary: str = "") -> list: diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 0dace43..ed3da8b 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -293,3 +293,19 @@ def test_numbers_and_words_are_not_mistaken_for_units(): from solving.solver_stage import check_answer_units run = _run_with_answer("Teil 2 zeigt: x1 = 6, 3 Maschinen, Stufe 2a, am Tag 3 sind alle 5 Aufträge erledigt.") assert check_answer_units(run, "Plane die Aufträge.") == [] + + +def test_repair_reaches_compounds_and_inflections(): + # beobachtet in Portfolio Nr. 2 und nur_formeln (17.09.2026), Stämme gegen ngerman geprüft + from solving.solver_stage import fix_transliterations + text = "Aufgefuehrt: Beitraege, Jahresvolatilitaet, Gesamtstueckzahl, zulaessige Kardinalitaet, vollstaendig." + assert fix_transliterations(text) == ("Aufgeführt: Beiträge, Jahresvolatilität, Gesamtstückzahl, " + "zulässige Kardinalität, vollständig.") + + +def test_repair_leaves_correct_words_and_identifiers_alone(): + from solving.solver_stage import fix_transliterations + richtig = "Die neue Dauer, Quelle, aktuell, Kostenkoeffizient, Bauer, streuen, blue." + assert fix_transliterations(richtig) == richtig + namen = "Bindend: Spaet_zu_Frueh_Verbot und KeinFruehNachSpaet sowie Kapazitaet_M1." + assert fix_transliterations(namen) == namen