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
2 changed files with 244 additions and 60 deletions

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