From 6ecfdf6ae0c846db5e2059b3125ed187cef3a68c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 18 Sep 2026 13:24:46 +0200 Subject: [PATCH] feat: Zielwert-Zerlegung als Reparaturgrund, SOLVER_TIMEOUT, Summenzeilen-Fehlalarm behoben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check_objective_decomposition: bei LP/MILP/CP muss eine objective_column den Zielwert belegen, eine abweichende Beitragsspalte ist überall ein Befund -> Solver-Reparatur, bleibt es: Warnung. 90 von 90 linearen Läufen seit 17.09. hätten bestanden. - Summenzeilen: „Gesamt…“/„Nutzen…“ nur noch in der letzten Zeile, einzelne Zeile nie, Zwischensummen nicht gegen die Gesamtsumme. Vorher 15 Rucksack-Läufe mit falschem „Summe (0) weicht vom Zielwert (51) ab“; von 17 Abweichungen bleibt 1 echte. - SOLVER_TIMEOUT (Default 60) für alle Programmläufe und als {ZEITLIMIT} im Solver-Prompt - Benchmark: dirty zählt nur versionierte Dateien; SOLVER_TIMEOUT in der Konfiguration Ändert die Prompt-Kennung (Solver-Prompt.txt). Co-Authored-By: Claude Opus 5 --- BEDIENUNGSANLEITUNG.md | 1 + README.md | 1 + TextToModel.py | 5 +- benchmark/benchmark.py | 5 +- reporting/report.py | 4 +- solving/Solver-Prompt.txt | 6 +- solving/solver_stage.py | 167 +++++++++++++++++++++++++----------- tests/test_pipeline.py | 47 ++++++++++ tests/test_result_tables.py | 51 +++++++++++ tests/test_script_export.py | 3 + tests/test_solver_stage.py | 14 +++ 11 files changed, 244 insertions(+), 60 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index ff4955a..89337f2 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -446,6 +446,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav | `LLM_TIMEOUT` | Zeitlimit je LLM-Aufruf in Sekunden | 420 | | `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe | | `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des Anbieters in Tokens/s | 75 | +| `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` | | `SOLVER_SANDBOX` | `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` (ungeschützt) | `auto` | Beispiel für einen einzelnen Lauf mit geänderten Einstellungen: diff --git a/README.md b/README.md index 597e57f..b653015 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverläss | `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des OpenRouter-Anbieters (Tokens/s); Präferenz, kein harter Filter | 75 | | `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 | | `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe | +| `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` | | `SOLVER_SANDBOX` | Isolierung des erzeugten Codes: `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` | `auto` | | `LLM_MODEL_UNDERSTANDING`, `LLM_MODEL_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` | diff --git a/TextToModel.py b/TextToModel.py index c24a1ff..ccf4b9b 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -19,7 +19,8 @@ from data import data_profile from modeling.extraction import revise_model, text_to_model from reporting.report import write_report from solving.script_export import build_solver_script -from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve +from solving.solver_stage import (SOLVER_TIMEOUT, check_feasibility, check_multistart, check_sensitivity, + run_solver_code, solve) from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions from understanding.understand import read_figures, understand_task @@ -374,7 +375,7 @@ def continue_pipeline( return result -def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = 60, +def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = SOLVER_TIMEOUT, data_files=()) -> PipelineResult: """Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf. diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 7b011b8..387d0c2 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -36,7 +36,7 @@ PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs- # Umgebungsvariablen, die das Verhalten ändern – ohne sie ließe sich ein A/B-Lauf nicht zuordnen CONFIG_VARS = ("LLM_MODEL", "LLM_MODEL_EXTRACTION", "LLM_MODEL_REVIEW", "LLM_MODEL_REVISION", "LLM_MODEL_SOLVER", "UNDERSTAND_RETRIES", "MAX_REVISIONS", "SOLVER_RETRIES", "MAX_RESTARTS", "INFEASIBLE_RECHECK", - "LLM_REASONING_EFFORT", "SOLVER_SANDBOX") + "LLM_REASONING_EFFORT", "SOLVER_SANDBOX", "SOLVER_TIMEOUT") def prompt_digest(paths=None) -> str: @@ -63,7 +63,8 @@ def prompt_state() -> dict: text=True, timeout=10).stdout.strip() try: state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt" - state["dirty"] = bool(git("status", "--porcelain")) + # nur versionierte Dateien: eine abgelegte Aufgabe in tasks/ ändert nichts am gemessenen Stand + state["dirty"] = bool(git("status", "--porcelain", "--untracked-files=no")) except (OSError, subprocess.SubprocessError): state["commit"], state["dirty"] = "unbekannt", None return state diff --git a/reporting/report.py b/reporting/report.py index eeed453..4bc9166 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -256,12 +256,12 @@ def _chart_for_table(table): return "" col = table.columns.index(table.objective_column) labels, values, total = [], [], None - for row in table.rows: + for n, row in enumerate(table.rows): value = row[col] if col < len(row) else None if not isinstance(value, (int, float)) or isinstance(value, bool): continue label = str(row[0]) if row else "" - if _is_sum_row(row): + if _is_sum_row(row, last=n == len(table.rows) - 1): total = (label, value) else: labels.append(label) diff --git a/solving/Solver-Prompt.txt b/solving/Solver-Prompt.txt index 3df910d..37e7cbc 100644 --- a/solving/Solver-Prompt.txt +++ b/solving/Solver-Prompt.txt @@ -78,7 +78,7 @@ Ergebnisausgabe: - sensitivity: Wirkung der bindenden Restriktionen, durch NEULÖSEN ermittelt (nicht aus Solver-Attributen ablesen). Für höchstens 8 bindende Restriktionen (linke Seite erreicht die Grenze genau), am besten die inhaltlich wichtigsten: {"name": "", "delta": 0>, "objective_plus": , "objective_minus": , "unit": "€ je Einheit"} - delta klein wählen (etwa 1 % der rechten Seite, mindestens eine sinnvolle Einheit), das Modell je Richtung einmal neu lösen und den Zielwert eintragen; bei unlösbar null. - - Nur ausführen, wenn das Neulösen schnell geht (Gesamtlaufzeit unter 60 s), sonst leere Liste. + - Nur ausführen, wenn das Neulösen schnell geht (Gesamtlaufzeit unter {ZEITLIMIT} s), sonst leere Liste. - relaxation_bound: bei ganzzahligen/binären Variablen der Zielwert der stetigen Relaxation (alle Ganzzahligkeitsforderungen aufgehoben), sonst null. Zeigt, was die Ganzzahligkeit kostet. - answers: Die Rückprojektion auf die gestellte Aufgabe – genau ein Eintrag je Teilfrage aus der Liste im Auftrag, mit deren question_id. Das gilt auch dann, wenn es nur EINE Teilfrage gibt: answers bleibt nie leer. [{"question_id": "a", "text": "…"}, {"question_id": "b", "text": "…"}] @@ -94,7 +94,7 @@ Ergebnisausgabe: - data_override enthält genau die Einträge von DATA, die sich ändern. Das Programm rechnet jedes Szenario selbst und trägt seinen objective_value ein. - Die Pipeline führt dein Programm anschließend mit jedem data_override ERNEUT aus und vergleicht – abweichende Werte fallen auf. Kein LLM ist daran beteiligt. [→ verify_scenarios] - Damit das funktioniert, muss das Programm ausschließlich aus DATA lesen; höchstens fünf Szenarien werden nachgerechnet. - - Jedes Szenario braucht einen objective_value – null nur, wenn es bewiesen unlösbar ist. Unterscheiden sich Szenarien nur in Zielgewichten, ist die Lösung eines Szenarios für die anderen zulässig: übergib sie als Startlösung, dann endet keines im Zeitlimit ohne Ergebnis. Zeitlimits so wählen, dass alle Läufe zusammen unter 60 s bleiben. [→ check_scenarios_solved] + - Jedes Szenario braucht einen objective_value – null nur, wenn es bewiesen unlösbar ist. Unterscheiden sich Szenarien nur in Zielgewichten, ist die Lösung eines Szenarios für die anderen zulässig: übergib sie als Startlösung, dann endet keines im Zeitlimit ohne Ergebnis. Zeitlimits so wählen, dass alle Läufe zusammen unter {ZEITLIMIT} s bleiben. [→ check_scenarios_solved] - graph: NUR bei Graphenproblemen (Stack NetworkX), sonst null. Grundlage für die Zeichnung im Report: {"nodes": ["0", "1", "2", "3"], "arcs": [{"source": "0", "target": "1", "weight": 8}, ...], @@ -120,7 +120,7 @@ Ergebnisausgabe: - Entscheidungswerte als formatierte Texte, Beiträge als Zahlen; Dezimalkomma im Text ist erlaubt. - unit: Einheit der objective_column für die Diagrammbeschriftung (z. B. " €", " %"), sonst weglassen. - note: ein Satz, wie die Tabelle zu lesen ist. - Erzeuge nach Möglichkeit diese drei Tabellen (in dieser Reihenfolge): + Erzeuge nach Möglichkeit diese drei Tabellen (in dieser Reihenfolge). Bei LP, MILP und CP ist die Zielwert-Zerlegung Pflicht (die Zielfunktion ist dort immer eine Summe), und jede objective_column muss sich zum Zielwert summieren – sonst wird das Programm zur Reparatur zurückgegeben. [→ check_objective_decomposition] 1. Die Objekttabelle wie oben beschrieben. 2. "Zielwert-Zerlegung": Zeilen für die Bestandteile der Zielfunktion (z. B. "Erwartete Rendite", "Risiko (λ·Varianz)", "Transaktionskosten") mit Spalten ["Bestandteil", "Wert", "Einheit"], Vorzeichen wie in der Zielfunktion (Abzüge negativ), objective_column "Wert", danach die Summenzeile "Zielwert". Alle Bestandteile müssen dieselbe Einheit haben; steht dort Unterschiedliches (z. B. "€" neben "dimensionslos"), ist das Modell fehlerhaft. 3. "Bindende Restriktionen": welche Grenzen die Lösung ausreizt, mit Spalten ["Restriktion", "Wert", "Grenze", "bindend"] und Werten aus der Lösung (z. B. "Höchstgewicht A02" | "30,00 %" | "30,00 %" | "ja"). Nur Tatsachen aus der Lösung, keine Bewertung; ohne objective_column. Die note erklärt „bindend" in Alltagssprache („die Lösung schöpft diese Grenze voll aus") – ohne Fachbegriffe. [→ check_text_quality] diff --git a/solving/solver_stage.py b/solving/solver_stage.py index b13f562..e8b7403 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -19,6 +19,7 @@ from core.llm import LLMSession from checks.model_checks import numbers_in, unexplained_jargon from core.model_schema import ( Answer, + ProblemClass, TargetStack, Scenario, ConstraintValue, @@ -40,6 +41,9 @@ RESULT_MARKER = "RESULT_JSON:" # Isolierung des erzeugten Codes: "auto" nutzt bubblewrap, wenn vorhanden; "bwrap" verlangt es # (Fehler, wenn es fehlt); "aus" führt ungeschützt aus (nur für Systeme ohne bubblewrap). SANDBOX = os.environ.get("SOLVER_SANDBOX", "auto").strip().lower() +# Zeitlimit je Programmlauf in Sekunden. Geht auch als Zeitbudget in den Solver-Prompt ({ZEITLIMIT}): ein höheres +# Limit nützt nur, wenn das Programm seine internen Solver-Zeitlimits danach wählt (SCIP-Szenarien, Portfolio Nr. 2). +SOLVER_TIMEOUT = int(os.environ.get("SOLVER_TIMEOUT", "60")) ALLOWED_MODULES = {"json", "math", "itertools", "functools", "collections", "numpy", "scipy", "highspy", "ortools", "cvxpy", "pyscipopt", "cyipopt", "networkx", "pandas"} FORBIDDEN_CALLS = {"__import__", "eval", "exec", "compile", "open", "input", "breakpoint"} @@ -260,13 +264,21 @@ def _tail(text: str, limit: int = 2000) -> str: SUM_ROW_LABELS = ("summe", "gesamt", "total", "σ", "sum", "zielwert", "ergebnis", "nutzen", "insgesamt") +# Vor der letzten Zeile zählt nur eine eindeutige Beschriftung: „Nutzen der eingepackten Gegenstände“ und +# „Gesamtnutzen …“ sind dort Bestandteile. Beobachtet: 15 Rucksack-Läufe meldeten „Summe (0) weicht vom +# Zielwert (51) ab“, weil der einzige Bestandteil als Summenzeile galt und aus der Summe fiel. +_INNER_SUM_ROW = re.compile(r"(zwischen)?summe\b.*|(gesamt|total|insgesamt|zielwert|σ|sum)\s*:?\s*", re.IGNORECASE) -def _is_sum_row(row, value=None, others=None) -> bool: - """Summenzeile: am Text erkennbar oder (letzte Zeile) am Wert = Summe der übrigen Zeilen.""" - if row and isinstance(row[0], str) and row[0].strip().lower().startswith(SUM_ROW_LABELS): +def _is_sum_row(row, value=None, others=None, last: bool = True) -> bool: + """Summenzeile: am Text erkennbar oder (letzte Zeile) am Wert = Summe der übrigen Zeilen. + + Wortanfänge wie „Gesamt…“ oder „Nutzen…“ zählen nur in der letzten Zeile, davor nur eindeutige Beschriftungen. + """ + label = row[0].strip().lower() if row and isinstance(row[0], str) else "" + if label and (label.startswith(SUM_ROW_LABELS) if last else _INNER_SUM_ROW.fullmatch(label)): return True - if value is None or not others: + if not last or value is None or not others: return False return _close(value, sum(others)) @@ -286,6 +298,60 @@ def _close(a: float, b: float) -> bool: return abs(a - b) <= 1e-3 * max(abs(a), abs(b)) + 1e-6 +def _is_number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def check_table(table: ResultTable, objective_value) -> tuple: + """Eine Tabelle gegen den Zielwert prüfen: (belegt, abweichung, Warnungen). + + `belegt` heißt: die Beitragsspalte summiert sich zum gemeldeten Zielwert – der Zielwert ist damit aus + Bestandteilen nachgerechnet und nicht bloß behauptet. `abweichung` ist die Meldung, wenn sie es nicht tut. + """ + if not table.objective_column: + return False, None, [] + if table.objective_column not in table.columns: + return False, None, [f"Tabelle '{table.title}': Spalte '{table.objective_column}' existiert nicht."] + col = table.columns.index(table.objective_column) + cells = [(row, row[col] if col < len(row) else None) for row in table.rows] + numeric = [v for _, v in cells if _is_number(v)] + letzte = len(cells) - 1 + + def summenzeile(n, row, v): + # letzte Zeile kann auch ohne Schlüsselwort die Summe sein (z. B. Beschriftung "Zielwert"); + # eine einzige Zeile ist nie eine Summe („Gesamtnutzen …“ als einziger Bestandteil) + if letzte == 0: + return False + return _is_sum_row(row, v if n == letzte else None, numeric[:-1] if n == letzte else None, last=n == letzte) + + values = [v for n, (row, v) in enumerate(cells) if not summenzeile(n, row, v)] + if not all(_is_number(v) for v in values): + return False, None, [f"Tabelle '{table.title}': Spalte '{table.objective_column}' enthält nicht nur " + f"Zahlen – Summe nicht prüfbar."] + total = sum(values) + if _is_comparison(values, len(values) == len(cells), total, objective_value): + return False, None, [] + warnings, abweichung = [], None + belegt = objective_value is not None and bool(values) and _close(total, objective_value) + if objective_value is not None and not _close(total, objective_value): + abweichung = (f"Tabelle '{table.title}': Summe der Spalte '{table.objective_column}' ({total:.6g}) " + f"weicht vom Zielwert ({objective_value:.6g}) ab – Tabelle bitte nicht verwenden.") + warnings.append(abweichung) + if len(values) > 1 and max(abs(v) for v in values) > 0: + largest = max(abs(v) for v in values) + tiny = [row[0] for row, v in cells if _is_number(v) and v in values and 0 < abs(v) < largest * 1e-4] + if tiny: + warnings.append( + f"Tabelle '{table.title}': {', '.join(str(t) for t in tiny)} ist um Größenordnungen kleiner " + f"als die übrigen Bestandteile – vermutlich uneinheitliche Einheiten (z. B. relativ statt absolut)." + ) + # nur die Schlusszeile: eine Zwischensumme weicht von der Gesamtsumme naturgemäß ab + if cells and _is_number(cells[-1][1]) and summenzeile(letzte, *cells[-1]) and not _close(cells[-1][1], total): + warnings.append(f"Tabelle '{table.title}': Summenzeile ({cells[-1][1]:.6g}) passt nicht zu den Zeilen " + f"({total:.6g}).") + return belegt, abweichung, warnings + + def parse_result_tables(raw_tables: list, objective_value) -> tuple: """Ergebnistabellen validieren und deterministisch gegen den Zielwert prüfen. @@ -299,47 +365,37 @@ def parse_result_tables(raw_tables: list, objective_value) -> tuple: warnings.append(f"Ergebnistabelle ungültig und verworfen: {str(exc)[:200]}") continue tables.append(table) - if not table.objective_column: - continue - if table.objective_column not in table.columns: - warnings.append(f"Tabelle '{table.title}': Spalte '{table.objective_column}' existiert nicht.") - continue - col = table.columns.index(table.objective_column) - cells = [(row, row[col] if col < len(row) else None) for row in table.rows] - numeric = [v for _, v in cells if isinstance(v, (int, float)) and not isinstance(v, bool)] - # letzte Zeile kann auch ohne Schlüsselwort die Summe sein (z. B. Beschriftung "Zielwert") - values = [v for n, (row, v) in enumerate(cells) - if not _is_sum_row(row, v if n == len(cells) - 1 else None, numeric[:-1] if n == len(cells) - 1 else None)] - if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values): - 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}) " - f"weicht vom Zielwert ({objective_value:.6g}) ab – Tabelle bitte nicht verwenden." - ) - if len(values) > 1 and max(abs(v) for v in values) > 0: - largest = max(abs(v) for v in values) - tiny = [row[0] for row, v in cells - if isinstance(v, (int, float)) and not isinstance(v, bool) and v in values - and 0 < abs(v) < largest * 1e-4] - if tiny: - warnings.append( - f"Tabelle '{table.title}': {', '.join(str(t) for t in tiny)} ist um Größenordnungen kleiner " - f"als die übrigen Bestandteile – vermutlich uneinheitliche Einheiten (z. B. relativ statt absolut)." - ) - for n, (row, value) in enumerate(cells): - if not isinstance(value, (int, float)) or isinstance(value, bool): - continue - if _is_sum_row(row, value if n == len(cells) - 1 else None, numeric[:-1] if n == len(cells) - 1 else None) \ - and not _close(value, total): - warnings.append(f"Tabelle '{table.title}': Summenzeile ({value:.6g}) passt nicht zu den Zeilen ({total:.6g}).") + warnings += check_table(table, objective_value)[2] return tables, warnings +# Lineare Zielfunktionen sind immer eine Summe von Bestandteilen – dort ist die Zerlegung Pflicht. Bei +# QP/SOCP/NLP nicht (Volatilität = Wurzel ist nicht additiv); eine vorhandene Beitragsspalte muss aber stimmen. +DECOMPOSITION_REQUIRED = {ProblemClass.LP, ProblemClass.MILP, ProblemClass.CP} +DECOMPOSITION_MISSING = "Zielwert nicht aus Bestandteilen belegt" + + +def check_objective_decomposition(run, spec: OptimizationModelSpecification) -> list: + """Ist der gemeldete Zielwert aus Bestandteilen nachgerechnet – oder nur behauptet? + + `check_feasibility` prüft die Restriktionen, nicht den Zielwert. Den belegt erst eine Beitragsspalte, + deren Summe ihn ergibt. Gemessen an 90 Läufen linearer Klassen seit dem 17.09.2026: jede hatte eine – + die Pflicht kostet also kaum Reparaturen, fängt aber Rückfälle. Bleibt der Befund nach den Reparaturen, + ist er eine Warnung: die Lösung selbst ist geprüft zulässig. + """ + if run.status not in (SolverStatus.OPTIMAL, SolverStatus.FEASIBLE) or run.objective_value is None: + return [] + geprueft = [check_table(t, run.objective_value) for t in run.tables] + befunde = [abweichung for _, abweichung, _ in geprueft if abweichung] + if spec.problem_class in DECOMPOSITION_REQUIRED and not any(belegt for belegt, _, _ in geprueft): + befunde.append( + f"{DECOMPOSITION_MISSING}: keine Ergebnistabelle hat eine objective_column, deren Summe den Zielwert " + f"{run.objective_value:.6g} ergibt. Die Zielfunktion ist linear, also eine Summe – melde die Tabelle " + f"„Zielwert-Zerlegung“ mit einer Zeile je Bestandteil, objective_column \"Wert\" und der Summenzeile " + f"„Zielwert“.") + return befunde + + def _parse_list(raw, model_type) -> list: """Fehlerhafte Einträge verwerfen statt den Lauf scheitern zu lassen (die Lösung ist davon unberührt).""" parsed = [] @@ -512,7 +568,7 @@ def solver_env(tmp: str) -> dict: return {**env, "HOME": tmp, "TMPDIR": tmp, "PYTHONDONTWRITEBYTECODE": "1"} -def run_solver_code(code: str, timeout: int = 60, data: dict | None = None, +def run_solver_code(code: str, timeout: int = SOLVER_TIMEOUT, data: dict | None = None, data_files=()) -> SolverRun: try: validate_imports(code) @@ -576,6 +632,11 @@ def run_solver_code(code: str, timeout: int = 60, data: dict | None = None, return SolverRun(status=SolverStatus.ERROR, error=f"Ungültiges Ergebnis von report(): {exc}: {result_lines[0][:500]}") +def solver_prompt() -> str: + """System-Prompt der Solver-Stufe mit dem Zeitbudget dieses Laufs (SOLVER_TIMEOUT).""" + return SOLVER_PROMPT_FILE.read_text(encoding="utf-8").replace("{ZEITLIMIT}", str(SOLVER_TIMEOUT)) + + def generate_solver_code( session: LLMSession, task: str, spec: OptimizationModelSpecification, previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None, @@ -597,7 +658,7 @@ def generate_solver_code( return session.create( SolverCode, [ - {"role": "system", "content": SOLVER_PROMPT_FILE.read_text(encoding="utf-8")}, + {"role": "system", "content": solver_prompt()}, {"role": "user", "content": content}, ], step="Solver-Reparatur" if previous is not None else "Solver-Code", @@ -746,7 +807,7 @@ def check_text_quality(run, summary: str = "") -> list: MAX_SCENARIOS = 5 # jedes Szenario kostet einen weiteren Programmlauf (kein LLM-Aufruf) -def verify_scenarios(run, code: str, timeout: int = 60, data_files=()) -> list: +def verify_scenarios(run, code: str, timeout: int = SOLVER_TIMEOUT, data_files=()) -> list: """Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen. Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`. @@ -797,7 +858,7 @@ def check_scenarios_solved(run) -> list: f"{szenario.verified_status or 'nicht ausgeführt'}) – vermutlich ein Zeitlimit ohne Lösung. " f"„Keine Lösung\" nur bei bewiesener Unlösbarkeit. Haben die Szenarien dieselben Restriktionen, die " f"Lösung eines anderen Szenarios als Startlösung übergeben; Zeitlimits so wählen, dass alle Läufe " - f"zusammen unter 60 s bleiben.") + f"zusammen unter {SOLVER_TIMEOUT} s bleiben.") return befunde @@ -1033,7 +1094,7 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, - retries: int = 2, timeout: int = 60, log=lambda msg: None, understanding=None, + retries: int = 2, timeout: int = SOLVER_TIMEOUT, log=lambda msg: None, understanding=None, data_files=(), data_prompt: str = "") -> tuple: """Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche. @@ -1047,16 +1108,18 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, 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) + zerlegung = check_objective_decomposition(run, spec) attempt = 1 while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios - or einheiten) and attempt <= retries: + or einheiten or zerlegung) and attempt <= retries: # Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch fehler = run.error or "\n\n".join( titel + ":\n- " + "\n- ".join(befunde) for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility), ("Eingabedaten nicht ausgewiesen", run.undeclared_data), ("Szenario ohne Ergebnis", run.unsolved_scenarios), - ("Einheit in der Antwort erfunden", einheiten)) + ("Einheit in der Antwort erfunden", einheiten), + ("Zielwert nicht nachvollziehbar", zerlegung)) if befunde) log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}") solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, @@ -1065,8 +1128,10 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, 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) + zerlegung = check_objective_decomposition(run, spec) attempt += 1 - # Bleibt die erfundene Einheit, ist das Ergebnis trotzdem richtig – Warnung, nicht unzuverlässig - run.warnings += einheiten + # Bleiben erfundene Einheit oder fehlende Zerlegung, ist die Lösung trotzdem geprüft zulässig – Warnung, nicht + # unzuverlässig. Eine abweichende Summe steht schon in den Warnungen (parse_result_tables). + run.warnings += einheiten + [z for z in zerlegung if z.startswith(DECOMPOSITION_MISSING)] run.attempts = attempt return solver_code, run diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index d3a062e..0b85364 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -412,6 +412,8 @@ def main(): """Loest und beantwortet.""" report("optimal", 9.0, {"x": 1.0}, headline="Die Kosten betragen 9 Geldeinheiten.", + tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], + "objective_column": "Wert", "rows": [["Kosten", 9.0]]}], constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."}, @@ -467,6 +469,8 @@ DATA = {"faktor": 1.0} def main(): """Meldet zwei Datenvarianten.""" report("optimal", 7.0, {"x": 1.0}, + tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], + "objective_column": "Wert", "rows": [["Kosten", 7.0]]}], constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt", @@ -689,3 +693,46 @@ if __name__ == "__main__": assert session.steps == ["Solver-Code", "Solver-Reparatur"] assert "hält das Modell nicht ein" in session.letzter_prompt assert "Eingabedaten nicht ausgewiesen" in session.letzter_prompt + + +# --- Zielwert-Zerlegung in der Solver-Stufe --- + +OHNE_ZERLEGUNG = STRUCTURED_CODE.split(" tables=[")[0] + " )\n\n\nif __name__ == \"__main__\":\n main()\n" + + +def test_missing_decomposition_is_repaired(): + assert "tables" not in OHNE_ZERLEGUNG + session = FakeSession([GOOD], [OK], [SolverCode(code=OHNE_ZERLEGUNG), SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert session.steps.count("Solver-Reparatur") == 1 + assert "Zielwert-Zerlegung" in session.letzter_prompt + assert result.solver_run.attempts == 2 and result.reliable + + +def test_persistently_missing_decomposition_is_only_a_warning(): + # Die Lösung ist geprüft zulässig – nur ihr Zielwert ist nicht aus Bestandteilen belegt + from solving.solver_stage import DECOMPOSITION_MISSING + session = FakeSession([GOOD], [OK], [SolverCode(code=OHNE_ZERLEGUNG)] * 3) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert result.reliable and result.solver_run.objective_value == 9 + assert any(w.startswith(DECOMPOSITION_MISSING) for w in result.solver_run.warnings) + + +def test_untracked_files_do_not_mark_the_measurement_dirty(monkeypatch): + # Eine abgelegte Aufgabe in tasks/ ließ bisher jede Messung als „nicht eingecheckte Änderungen“ erscheinen + import subprocess + from benchmark import benchmark + aufrufe = [] + + def git(befehl, **_): + aufrufe.append(befehl) + return subprocess.CompletedProcess(befehl, 0, stdout="abc1234\n" if "rev-parse" in befehl else "") + + monkeypatch.setattr(benchmark.subprocess, "run", git) + benchmark.prompt_state.cache_clear() + try: + stand = benchmark.prompt_state() + finally: + benchmark.prompt_state.cache_clear() + assert stand["dirty"] is False + assert any("--untracked-files=no" in befehl for befehl in aufrufe) diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 3f3cbb7..990b01c 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -345,3 +345,54 @@ def test_english_unit_words_cover_german_answers(): 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 + + +# --- Summenzeilen und Zielwert-Zerlegung (check_table, check_objective_decomposition) --- + +def _tabelle(rows): + from core.model_schema import ResultTable + return ResultTable(title="Zielwert-Zerlegung", columns=["Bestandteil", "Wert"], rows=rows, objective_column="Wert") + + +@pytest.mark.parametrize("rows", [ + [["Nutzen der eingepackten Gegenstände", 51.0], ["Zielwert", 51.0]], # beobachtet: 15 Rucksack-Läufe + [["Gesamtnutzen der eingepackten Gegenstände", 51]], # einzige Zeile ist keine Summe + [["Nutzen Kocher", 13.0], ["Nutzen Schlafsack", 23.0], ["Nutzen Proviant", 15.0], ["Zielwert", 51.0]], + [["Kocher", 13.0], ["Schlafsack", 23.0], ["Zwischensumme", 36.0], ["Proviant", 15.0], ["Summe", 51.0]], +]) +def test_components_named_like_a_sum_are_not_dropped(rows): + from solving.solver_stage import check_table + belegt, abweichung, warnungen = check_table(_tabelle(rows), 51.0) + assert belegt and abweichung is None and warnungen == [] + + +def test_wrong_decomposition_is_still_detected(): + from solving.solver_stage import check_table + belegt, abweichung, _ = check_table(_tabelle([["Kocher", 13.0], ["Schlafsack", 23.0], ["Summe", 36.0]]), 51.0) + assert not belegt and "weicht vom Zielwert" in abweichung + + +def _lauf(tables, status="optimal", objective=51.0): + from core.model_schema import SolverRun, SolverStatus + return SolverRun(status=SolverStatus(status), objective_value=objective, tables=tables) + + +def test_linear_model_without_decomposition_is_a_finding(): + from solving.solver_stage import DECOMPOSITION_MISSING, check_objective_decomposition + befunde = check_objective_decomposition(_lauf([]), make_spec()) + assert len(befunde) == 1 and befunde[0].startswith(DECOMPOSITION_MISSING) + + +def test_decomposition_is_optional_for_non_additive_objectives(): + # Volatilität = Wurzel: keine Summe, also keine Pflicht – eine vorhandene Beitragsspalte muss aber stimmen + from solving.solver_stage import check_objective_decomposition + spec = make_spec(problem_class="Second-Order Cone Programming (SOCP)", target_stack="CVXPY") + assert check_objective_decomposition(_lauf([]), spec) == [] + falsch = _tabelle([["Rendite", 0.1], ["Risiko", -0.02]]) + assert "weicht vom Zielwert" in check_objective_decomposition(_lauf([falsch], objective=0.5), spec)[0] + + +def test_no_decomposition_needed_without_a_solution(): + from solving.solver_stage import check_objective_decomposition + assert check_objective_decomposition(_lauf([], status="infeasible", objective=None), make_spec()) == [] + assert check_objective_decomposition(_lauf([_tabelle([["Kosten", 51.0]])]), make_spec()) == [] diff --git a/tests/test_script_export.py b/tests/test_script_export.py index 18716b4..7920b18 100644 --- a/tests/test_script_export.py +++ b/tests/test_script_export.py @@ -35,6 +35,9 @@ def main(): constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}], variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3}, {"name": "y", "value": y, "var_type": "continuous", "lb": 0, "ub": 3}], + tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], "objective_column": "Wert", + "rows": [["Kosten Rohstoff X", COSTS[0] * x], ["Kosten Rohstoff Y", COSTS[1] * y], + ["Zielwert", float(res.fun)]]}], ) diff --git a/tests/test_solver_stage.py b/tests/test_solver_stage.py index 71e3c79..c4938c8 100644 --- a/tests/test_solver_stage.py +++ b/tests/test_solver_stage.py @@ -870,3 +870,17 @@ def test_sandbox_hides_the_home_directory(): assert run.status == SolverStatus.OPTIMAL, run.error # Sichtbar bleiben höchstens die Einhängepunkte der Python-Installationen (.venv, Basis-Python) assert run.solution["home_eintraege"] <= 2 + + +def test_time_budget_in_the_prompt_follows_solver_timeout(monkeypatch): + # Ein höheres Zeitlimit nützt nur, wenn auch das Programm seine internen Solver-Zeitlimits danach wählt + from solving import solver_stage + assert "{ZEITLIMIT}" not in solver_stage.solver_prompt() and "unter 60 s" in solver_stage.solver_prompt() + monkeypatch.setattr(solver_stage, "SOLVER_TIMEOUT", 180) + assert "unter 180 s" in solver_stage.solver_prompt() and "unter 60 s" not in solver_stage.solver_prompt() + + +def test_timeout_ends_a_slow_program(): + code = "def main():\n while True:\n pass\n\n\nif __name__ == '__main__':\n main()\n" + run = run_solver_code(code, timeout=1) + assert run.status == SolverStatus.ERROR and "Timeout nach 1 s" in run.error