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_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:

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 |
| `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` |

View file

@ -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.

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
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

View file

@ -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)

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:
{"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.
- 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]

View file

@ -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

View file

@ -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)

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

View file

@ -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)]]}],
)

View file

@ -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