feat: Zielwert-Zerlegung als Reparaturgrund, SOLVER_TIMEOUT, Summenzeilen-Fehlalarm behoben

- 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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 13:24:46 +02:00
commit 6ecfdf6ae0
11 changed files with 244 additions and 60 deletions

View file

@ -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_TIMEOUT` | Zeitlimit je LLM-Aufruf in Sekunden | 420 |
| `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe | | `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe |
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des Anbieters in Tokens/s | 75 | | `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` | | `SOLVER_SANDBOX` | `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` (ungeschützt) | `auto` |
Beispiel für einen einzelnen Lauf mit geänderten Einstellungen: Beispiel für einen einzelnen Lauf mit geänderten Einstellungen:

View file

@ -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 | | `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_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 |
| `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe | | `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` | | `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` | | `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` |

View file

@ -19,7 +19,8 @@ from data import data_profile
from modeling.extraction import revise_model, text_to_model from modeling.extraction import revise_model, text_to_model
from reporting.report import write_report from reporting.report import write_report
from solving.script_export import build_solver_script 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.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions
from understanding.understand import read_figures, understand_task from understanding.understand import read_figures, understand_task
@ -374,7 +375,7 @@ def continue_pipeline(
return result 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: data_files=()) -> PipelineResult:
"""Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf. """Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf.

View file

@ -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 # 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", 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", "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: def prompt_digest(paths=None) -> str:
@ -63,7 +63,8 @@ def prompt_state() -> dict:
text=True, timeout=10).stdout.strip() text=True, timeout=10).stdout.strip()
try: try:
state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt" 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): except (OSError, subprocess.SubprocessError):
state["commit"], state["dirty"] = "unbekannt", None state["commit"], state["dirty"] = "unbekannt", None
return state return state

View file

@ -256,12 +256,12 @@ def _chart_for_table(table):
return "" return ""
col = table.columns.index(table.objective_column) col = table.columns.index(table.objective_column)
labels, values, total = [], [], None 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 value = row[col] if col < len(row) else None
if not isinstance(value, (int, float)) or isinstance(value, bool): if not isinstance(value, (int, float)) or isinstance(value, bool):
continue continue
label = str(row[0]) if row else "" 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) total = (label, value)
else: else:
labels.append(label) labels.append(label)

View file

@ -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: - 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": "<wie in constraints>", "delta": <Verschiebung > 0>, "objective_plus": <Zielwert mit rhs+delta oder null>, "objective_minus": <Zielwert mit rhs-delta oder null>, "unit": "€ je Einheit"} {"name": "<wie in constraints>", "delta": <Verschiebung > 0>, "objective_plus": <Zielwert mit rhs+delta oder null>, "objective_minus": <Zielwert mit rhs-delta oder null>, "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. - 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. - 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. - 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": "…"}] [{"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. - 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] - 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. - 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: - graph: NUR bei Graphenproblemen (Stack NetworkX), sonst null. Grundlage für die Zeichnung im Report:
{"nodes": ["0", "1", "2", "3"], {"nodes": ["0", "1", "2", "3"],
"arcs": [{"source": "0", "target": "1", "weight": 8}, ...], "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. - 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. - unit: Einheit der objective_column für die Diagrammbeschriftung (z. B. " €", " %"), sonst weglassen.
- note: ein Satz, wie die Tabelle zu lesen ist. - 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. 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. 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] 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]

View file

@ -19,6 +19,7 @@ from core.llm import LLMSession
from checks.model_checks import numbers_in, unexplained_jargon from checks.model_checks import numbers_in, unexplained_jargon
from core.model_schema import ( from core.model_schema import (
Answer, Answer,
ProblemClass,
TargetStack, TargetStack,
Scenario, Scenario,
ConstraintValue, ConstraintValue,
@ -40,6 +41,9 @@ RESULT_MARKER = "RESULT_JSON:"
# Isolierung des erzeugten Codes: "auto" nutzt bubblewrap, wenn vorhanden; "bwrap" verlangt es # 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). # (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() 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"} 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"} 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") 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: 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.""" """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):
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 return True
if value is None or not others: if not last or value is None or not others:
return False return False
return _close(value, sum(others)) 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 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: def parse_result_tables(raw_tables: list, objective_value) -> tuple:
"""Ergebnistabellen validieren und deterministisch gegen den Zielwert prüfen. """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]}") warnings.append(f"Ergebnistabelle ungültig und verworfen: {str(exc)[:200]}")
continue continue
tables.append(table) tables.append(table)
if not table.objective_column: warnings += check_table(table, objective_value)[2]
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}).")
return tables, warnings 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: def _parse_list(raw, model_type) -> list:
"""Fehlerhafte Einträge verwerfen statt den Lauf scheitern zu lassen (die Lösung ist davon unberührt).""" """Fehlerhafte Einträge verwerfen statt den Lauf scheitern zu lassen (die Lösung ist davon unberührt)."""
parsed = [] parsed = []
@ -512,7 +568,7 @@ def solver_env(tmp: str) -> dict:
return {**env, "HOME": tmp, "TMPDIR": tmp, "PYTHONDONTWRITEBYTECODE": "1"} 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: data_files=()) -> SolverRun:
try: try:
validate_imports(code) 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]}") 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( def generate_solver_code(
session: LLMSession, task: str, spec: OptimizationModelSpecification, session: LLMSession, task: str, spec: OptimizationModelSpecification,
previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None, previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None,
@ -597,7 +658,7 @@ def generate_solver_code(
return session.create( return session.create(
SolverCode, SolverCode,
[ [
{"role": "system", "content": SOLVER_PROMPT_FILE.read_text(encoding="utf-8")}, {"role": "system", "content": solver_prompt()},
{"role": "user", "content": content}, {"role": "user", "content": content},
], ],
step="Solver-Reparatur" if previous is not None else "Solver-Code", 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) 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. """Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen.
Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`. 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"{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"„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"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 return befunde
@ -1033,7 +1094,7 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification
def solve(session: LLMSession, task: str, 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: data_files=(), data_prompt: str = "") -> tuple:
"""Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche. """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 = _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) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
einheiten = check_answer_units(run, task, data_prompt) einheiten = check_answer_units(run, task, data_prompt)
zerlegung = check_objective_decomposition(run, spec)
attempt = 1 attempt = 1
while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios 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 # Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch
fehler = run.error or "\n\n".join( fehler = run.error or "\n\n".join(
titel + ":\n- " + "\n- ".join(befunde) titel + ":\n- " + "\n- ".join(befunde)
for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility), for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility),
("Eingabedaten nicht ausgewiesen", run.undeclared_data), ("Eingabedaten nicht ausgewiesen", run.undeclared_data),
("Szenario ohne Ergebnis", run.unsolved_scenarios), ("Szenario ohne Ergebnis", run.unsolved_scenarios),
("Einheit in der Antwort erfunden", einheiten)) ("Einheit in der Antwort erfunden", einheiten),
("Zielwert nicht nachvollziehbar", zerlegung))
if befunde) if befunde)
log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}") log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}")
solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, 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 = _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) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
einheiten = check_answer_units(run, task, data_prompt) einheiten = check_answer_units(run, task, data_prompt)
zerlegung = check_objective_decomposition(run, spec)
attempt += 1 attempt += 1
# Bleibt die erfundene Einheit, ist das Ergebnis trotzdem richtig – Warnung, nicht unzuverlässig # Bleiben erfundene Einheit oder fehlende Zerlegung, ist die Lösung trotzdem geprüft zulässig – Warnung, nicht
run.warnings += einheiten # 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 run.attempts = attempt
return solver_code, run return solver_code, run

View file

@ -412,6 +412,8 @@ def main():
"""Loest und beantwortet.""" """Loest und beantwortet."""
report("optimal", 9.0, {"x": 1.0}, report("optimal", 9.0, {"x": 1.0},
headline="Die Kosten betragen 9 Geldeinheiten.", 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}], constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}],
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}],
answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."}, answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."},
@ -467,6 +469,8 @@ DATA = {"faktor": 1.0}
def main(): def main():
"""Meldet zwei Datenvarianten.""" """Meldet zwei Datenvarianten."""
report("optimal", 7.0, {"x": 1.0}, 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}], constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}],
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}],
scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt", scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt",
@ -689,3 +693,46 @@ if __name__ == "__main__":
assert session.steps == ["Solver-Code", "Solver-Reparatur"] assert session.steps == ["Solver-Code", "Solver-Reparatur"]
assert "hält das Modell nicht ein" in session.letzter_prompt assert "hält das Modell nicht ein" in session.letzter_prompt
assert "Eingabedaten nicht ausgewiesen" 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)

View file

@ -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." 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("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 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()) == []

View file

@ -35,6 +35,9 @@ def main():
constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}], constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}],
variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3}, variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3},
{"name": "y", "value": y, "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)]]}],
) )

View file

@ -870,3 +870,17 @@ def test_sandbox_hides_the_home_directory():
assert run.status == SolverStatus.OPTIMAL, run.error assert run.status == SolverStatus.OPTIMAL, run.error
# Sichtbar bleiben höchstens die Einhängepunkte der Python-Installationen (.venv, Basis-Python) # Sichtbar bleiben höchstens die Einhängepunkte der Python-Installationen (.venv, Basis-Python)
assert run.solution["home_eintraege"] <= 2 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