feat: Warnung bei ungeschützter Sandbox, Prüf-Regel für konstante Kosten, Grund je Korrekturrunde

- sandbox_warning: fehlt bubblewrap oder ist SOLVER_SANDBOX=aus, steht das im Log
  und in SolverRun.warnings (vorher lief der Code stillschweigend ungeschützt)
- Dijkstra-Markierung: der Report erklärt den Begriff selbst (DIJKSTRA_EXPLANATION),
  check_text_quality zählt das als Erklärung; Stamm 'markier' -> 'markierung'
- Pruef-Prompt Regel 7: entscheidungsunabhängige Terme ohne Zahlenwert dürfen
  weggelassen oder symbolisch mitgeführt werden (wagner_whitin_dijkstra: 1+1 statt
  2+1 Korrekturrunden, n=2)
- revision_reasons: jede Korrekturrunde nennt ihren Auslöser im Log

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 16:28:39 +02:00
commit 0f1ab04a8d
9 changed files with 148 additions and 8 deletions

View file

@ -6,7 +6,7 @@ from pathlib import Path
from typing import List from typing import List
from checks.model_checks import check_spec, check_understanding, has_errors from checks.model_checks import check_spec, check_understanding, has_errors
from checks.review import needs_work, review_assumptions, select_best from checks.review import needs_work, review_assumptions, revision_reasons, select_best
from core.llm import DEFAULT_MODEL, LLMSession from core.llm import DEFAULT_MODEL, LLMSession
from core.model_schema import ( from core.model_schema import (
AssumptionReview, AssumptionReview,
@ -89,6 +89,8 @@ def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str
while needs_work(review, issues) and revisions < max_revisions: while needs_work(review, issues) and revisions < max_revisions:
revisions += 1 revisions += 1
log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...") log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...")
for grund in revision_reasons(review, issues):
log(f" Grund: {grund}")
spec = revise_model(session, task, spec, review, issues) spec = revise_model(session, task, spec, review, issues)
issues = check_spec(spec, task, understanding) issues = check_spec(spec, task, understanding)
review = review_assumptions(session, task, spec) review = review_assumptions(session, task, spec)

View file

@ -22,6 +22,7 @@ Schritt 1 – Zielgröße (objective_check), immer zuerst:
- Kosten in Tsd. €, Erlöse in €. - Kosten in Tsd. €, Erlöse in €.
Bei False gehört in correction die korrigierte Zielfunktion mit einheitlicher Einheit (z. B. Risiko mit dem Kapital multipliziert). Bei False gehört in correction die korrigierte Zielfunktion mit einheitlicher Einheit (z. B. Risiko mit dem Kapital multipliziert).
6. Bei matches False gib in correction die korrigierte Zielfunktion in LaTeX an (ohne Begrenzer). 6. Bei matches False gib in correction die korrigierte Zielfunktion in LaTeX an (ohne Begrenzer).
7. Ausnahme zu 4 – entscheidungsunabhängige Terme, deren Wert der Text NICHT nennt: Hat ein Term für jede zulässige Lösung denselben Wert (z. B. gleichbleibender Einkaufspreis × fest vorgegebener Gesamtbedarf), ändert er die optimale Entscheidung nicht, und ohne Zahl im Text lässt er sich nicht ausrechnen. Dann sind zwei Formen gleich richtig: (a) den Term weglassen oder (b) ihn als Konstante mit Symbol mitführen. Beide erfüllen matches = True, wenn eine Annahme in open_assumptions ausdrücklich sagt, dass der Zielwert diesen Term nicht bzw. nur symbolisch enthält und warum die Entscheidung davon unberührt ist. Verlange keine Umstellung von (a) auf (b) oder umgekehrt, und führe den fehlenden Zahlenwert nicht als missing_assumptions auf, wenn eine solche Annahme existiert. Fehlt die Annahme, gehört genau sie in missing_assumptions. Nennt der Text den Wert, gilt Regel 4: dann gehört der Term in die gefragte Gesamtgröße.
Schreibweise: In allen Textfeldern (task_evidence ausgenommen, das ist ein wörtliches Zitat) steht jede Formel und jedes Symbol in \( … \), z. B. „modelliert wird \(w^\top \Sigma w\)“. Keine $-Zeichen als Formelbegrenzer. Schreibweise: In allen Textfeldern (task_evidence ausgenommen, das ist ein wörtliches Zitat) steht jede Formel und jedes Symbol in \( … \), z. B. „modelliert wird \(w^\top \Sigma w\)“. Keine $-Zeichen als Formelbegrenzer.

View file

@ -69,7 +69,8 @@ JARGON = {
"Epigraph": ("epigraph",), "Epigraph": ("epigraph",),
"Schattenpreis": ("schattenpreis", "dualwert", "dualvariable"), "Schattenpreis": ("schattenpreis", "dualwert", "dualvariable"),
"Konvexität": ("konvex",), "Konvexität": ("konvex",),
"Markierung": ("marke", "markier"), # nicht "markier": „die mit ja markierten Zeilen“ ist Alltagssprache (Fehlalarm in 20260917-160618)
"Markierung": ("marke", "markierung"),
"Inzidenz": ("inzidenz",), "Inzidenz": ("inzidenz",),
"Big-M": ("big-m", "big m"), "Big-M": ("big-m", "big m"),
"Subtour": ("subtour",), "Subtour": ("subtour",),

View file

@ -4,7 +4,7 @@ from typing import List
from checks.model_checks import has_errors from checks.model_checks import has_errors
from core.llm import LLMSession from core.llm import LLMSession
from core.model_schema import AssumptionReview, CheckIssue, CheckSeverity, OptimizationModelSpecification from core.model_schema import AssumptionReview, AssumptionVerdict, CheckIssue, CheckSeverity, OptimizationModelSpecification
REVIEW_PROMPT_FILE = Path(__file__).parent / "Pruef-Prompt.txt" REVIEW_PROMPT_FILE = Path(__file__).parent / "Pruef-Prompt.txt"
@ -34,6 +34,30 @@ def needs_work(review: AssumptionReview, issues: List[CheckIssue]) -> bool:
or any(i.code in REVISION_WORTHY_WARNINGS for i in issues)) or any(i.code in REVISION_WORTHY_WARNINGS for i in issues))
def revision_reasons(review: AssumptionReview, issues: List[CheckIssue]) -> List[str]:
"""Warum eine Korrekturrunde nötig ist – fürs Log.
Bisher stand dort nur „Probleme gefunden“; welche Beanstandung eine Runde auslöste, ließ sich im
Nachhinein nicht mehr feststellen (beobachtet an wagner_whitin_dijkstra, 20260917-160618).
"""
gruende = []
oc = review.objective_check
if not oc.matches:
gruende.append(f"Zielgröße passt nicht: {oc.explanation[:160]}")
if not oc.units_consistent:
gruende.append(f"Einheiten der Zielfunktion uneinheitlich: {oc.unit_explanation[:160]}")
faulty = {AssumptionVerdict.CONTRADICTS_TASK, AssumptionVerdict.STRICTER_THAN_TASK, AssumptionVerdict.WEAKER_THAN_TASK}
for check in review.checks:
if check.verdict in faulty:
gruende.append(f"Annahme {check.verdict.value}: {check.unclear_point[:120]}")
for fehlend in review.missing_assumptions:
gruende.append(f"fehlende Annahme: {fehlend.unclear_point[:120]}")
for issue in issues:
if issue.severity == CheckSeverity.ERROR or issue.code in REVISION_WORTHY_WARNINGS:
gruende.append(f"[{issue.code}] {issue.message[:120]}")
return gruende
def select_best(candidates: List[tuple]) -> int: def select_best(candidates: List[tuple]) -> int:
"""Index der besten (spec, issues, review)-Runde. """Index der besten (spec, issues, review)-Runde.

View file

@ -12,7 +12,7 @@ from pathlib import Path
from reporting import charts from reporting import charts
from core.model_schema import PipelineResult, SolverStatus from core.model_schema import PipelineResult, SolverStatus
from solving.script_export import build_solver_script from solving.script_export import build_solver_script
from solving.solver_stage import _is_sum_row from solving.solver_stage import DIJKSTRA_EXPLANATION, _is_sum_row
class Math(str): class Math(str):
@ -140,9 +140,7 @@ def _graph_section(run) -> list:
doc += [("h4", "Markierungsverlauf des Dijkstra-Algorithmus"), doc += [("h4", "Markierungsverlauf des Dijkstra-Algorithmus"),
("table", ["Schritt", "Knoten wird endgültig", "Marke (Entfernung von der Quelle)"], ("table", ["Schritt", "Knoten wird endgültig", "Marke (Entfernung von der Quelle)"],
[[str(i), s.node, _fmt_number(s.label)] for i, s in enumerate(g.steps, 1)]), [[str(i), s.node, _fmt_number(s.label)] for i, s in enumerate(g.steps, 1)]),
("p", "Dijkstra nimmt in jedem Schritt den noch nicht endgültigen Knoten mit der kleinsten Marke. " ("p", DIJKSTRA_EXPLANATION)]
"Weil die Marken dabei nie fallen, steht jede endgültige Marke schon fest, sobald sie "
"vergeben wird – das ist der Grund, warum ein Durchlauf genügt.")]
return doc return doc

View file

@ -444,6 +444,23 @@ def check_constraint_coverage(constraints: list, spec: OptimizationModelSpecific
return problems return problems
def sandbox_warning() -> Optional[str]:
"""Hinweis, wenn der erzeugte Code ungeschützt läuft – sonst None.
Mit SOLVER_SANDBOX=auto lief der Code ohne bubblewrap bisher stillschweigend mit den Rechten des
Nutzers; aufgefallen beim Schreiben der Bedienungsanleitung. Der Hinweis steht deshalb im Log und
in `SolverRun.warnings` (damit im Report), bewusst ohne den Lauf zu verweigern – dafür gibt es `bwrap`.
"""
if SANDBOX == "aus":
return ("Sandbox abgeschaltet (SOLVER_SANDBOX=aus): der vom LLM erzeugte Code lief ohne Isolierung "
"mit den Rechten des Benutzers.")
if SANDBOX != "bwrap" and not shutil.which("bwrap"):
return ("Sandbox fehlt: bubblewrap ist nicht installiert, der vom LLM erzeugte Code lief ohne Isolierung "
"mit den Rechten des Benutzers. Abhilfe: 'sudo apt install bubblewrap'; "
"SOLVER_SANDBOX=bwrap verweigert ungeschützte Läufe.")
return None
def sandbox_command(script: Path, tmp: str) -> List[str]: def sandbox_command(script: Path, tmp: str) -> List[str]:
"""Aufrufzeile für den Solver-Lauf – mit bubblewrap isoliert, sonst direkt. """Aufrufzeile für den Solver-Lauf – mit bubblewrap isoliert, sonst direkt.
@ -667,18 +684,30 @@ def fix_transliterations(text: str, extra: dict | None = None) -> str:
return re.sub(r"\w+", wort, text or "") return re.sub(r"\w+", wort, text or "")
# Feste Erklärung im Report (Graphen-Abschnitt, steht VOR den Ergebnistabellen), sobald Dijkstra-Schritte da sind
DIJKSTRA_EXPLANATION = ("Dijkstra nimmt in jedem Schritt den noch nicht endgültigen Knoten mit der kleinsten Marke. "
"Weil die Marken dabei nie fallen, steht jede endgültige Marke schon fest, sobald sie "
"vergeben wird – das ist der Grund, warum ein Durchlauf genügt.")
def check_text_quality(run, summary: str = "") -> list: def check_text_quality(run, summary: str = "") -> list:
"""Texte für Menschen prüfen (ohne LLM): unerklärte Fachbegriffe in Antwortsatz und Tabellen. """Texte für Menschen prüfen (ohne LLM): unerklärte Fachbegriffe in Antwortsatz und Tabellen.
Umschriebene Umlaute werden nicht mehr gemeldet, sondern still berichtigt (`repair_texts`): Umschriebene Umlaute werden nicht mehr gemeldet, sondern still berichtigt (`repair_texts`):
die Warnung stand in fast jedem Lauf und bewirkte nichts – im Code sind Umschreibungen erlaubt. die Warnung stand in fast jedem Lauf und bewirkte nichts – im Code sind Umschreibungen erlaubt.
Als Erklärung zählt neben `executive_summary` auch, was der Report selbst vor den Tabellen erklärt:
der Markierungsverlauf bei Dijkstra. Beobachtet (20260917-160618): „Marke“ in beiden Dijkstra-Läufen
gemeldet, obwohl die Aufgabe Dijkstra ausdrücklich verlangt und der Report den Begriff erklärt – die
Zusammenfassung entsteht vor der Solver-Stufe und kennt ihn noch nicht.
""" """
texte = [("headline", run.headline or "")] texte = [("headline", run.headline or "")]
for table in run.tables: for table in run.tables:
texte.append((f"Tabelle '{table.title}'", " ".join([table.title, *map(str, table.columns), table.note or ""]))) texte.append((f"Tabelle '{table.title}'", " ".join([table.title, *map(str, table.columns), table.note or ""])))
befunde = [] befunde = []
if summary: if summary:
offen = unexplained_jargon(" ".join(text for _, text in texte), summary) erklaert = summary + (" " + DIJKSTRA_EXPLANATION if run.graph and run.graph.steps else "")
offen = unexplained_jargon(" ".join(text for _, text in texte), erklaert)
if offen: if offen:
befunde.append(f"Fachbegriffe in Antwortsatz oder Ergebnistabellen, die die Zusammenfassung nicht " befunde.append(f"Fachbegriffe in Antwortsatz oder Ergebnistabellen, die die Zusammenfassung nicht "
f"erklärt: {', '.join(offen)}.") f"erklärt: {', '.join(offen)}.")
@ -953,6 +982,8 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification
except CodeRejected as exc: except CodeRejected as exc:
return SolverRun(status=SolverStatus.ERROR, error=f"Code abgelehnt: {exc}") return SolverRun(status=SolverStatus.ERROR, error=f"Code abgelehnt: {exc}")
run = run_solver_code(code, timeout=timeout, data_files=data_files) run = run_solver_code(code, timeout=timeout, data_files=data_files)
if (ungeschuetzt := sandbox_warning()):
run.warnings.append(ungeschuetzt)
run.feasibility = check_feasibility(run, spec) run.feasibility = check_feasibility(run, spec)
run.warnings += check_sensitivity(run, spec.objective.sense.value) run.warnings += check_sensitivity(run, spec.objective.sense.value)
run.warnings += check_multistart(run, spec.objective.sense.value) run.warnings += check_multistart(run, spec.objective.sense.value)
@ -974,6 +1005,8 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
infeasible/unbounded gelten nicht als Fehler und werden nicht repariert. infeasible/unbounded gelten nicht als Fehler und werden nicht repariert.
""" """
if (ungeschuetzt := sandbox_warning()):
log(f"WARNUNG – {ungeschuetzt}")
solver_code = generate_solver_code(session, task, spec, understanding=understanding, solver_code = generate_solver_code(session, task, spec, understanding=understanding,
data_prompt=data_prompt) data_prompt=data_prompt)
solver_code.code = clean_generated_code(solver_code.code) solver_code.code = clean_generated_code(solver_code.code)

View file

@ -309,3 +309,31 @@ def test_repair_leaves_correct_words_and_identifiers_alone():
assert fix_transliterations(richtig) == richtig assert fix_transliterations(richtig) == richtig
namen = "Bindend: Spaet_zu_Frueh_Verbot und KeinFruehNachSpaet sowie Kapazitaet_M1." namen = "Bindend: Spaet_zu_Frueh_Verbot und KeinFruehNachSpaet sowie Kapazitaet_M1."
assert fix_transliterations(namen) == namen assert fix_transliterations(namen) == namen
def _dijkstra_run(title, note, steps=True):
from core.model_schema import GraphSolution, ResultTable
graph = GraphSolution.model_validate({
"nodes": ["0", "1"], "arcs": [{"source": "0", "target": "1", "weight": 8}], "path": ["0", "1"],
"steps": [{"node": "0", "label": 0}, {"node": "1", "label": 8}] if steps else [],
"node_meaning": "Zeitpunkt", "arc_meaning": "Kauf und Nutzung"})
return SolverRun(status=SolverStatus.OPTIMAL, objective_value=8, graph=graph,
tables=[ResultTable(title=title, columns=["Knoten", "Marke"], rows=[["0", 0]], note=note)])
def test_dijkstra_marks_explained_by_the_report_are_not_flagged():
# beobachtet: „Dijkstra-Markierungen“ gemeldet, obwohl der Report den Markierungsverlauf erklärt
from solving.solver_stage import check_text_quality
run = _dijkstra_run("Dijkstra-Markierungen", "Die Knoten in der Reihenfolge ihrer Markierung.")
assert check_text_quality(run, "Ersatzplanung als kürzester Weg.") == []
def test_marks_without_dijkstra_steps_are_still_flagged():
from solving.solver_stage import check_text_quality
run = _dijkstra_run("Marken je Knoten", None, steps=False)
assert any("Markierung" in b for b in check_text_quality(run, "Ersatzplanung als kürzester Weg."))
def test_everyday_marked_is_no_jargon():
from checks.model_checks import unexplained_jargon
assert unexplained_jargon("nur die mit 'ja' markierten Kombinationen gehören zum Plan", "Zusammenfassung") == []

View file

@ -64,3 +64,33 @@ def test_fewer_errors_preferred_when_all_have_errors():
def test_mixed_units_trigger_revision(): def test_mixed_units_trigger_revision():
# beobachtet: Rendite/Kosten in Euro, Risiko als dimensionslose Summe w_i^2 sigma_i^2 (Faktor 100.000 zu klein) # beobachtet: Rendite/Kosten in Euro, Risiko als dimensionslose Summe w_i^2 sigma_i^2 (Faktor 100.000 zu klein)
assert make_review(units_consistent=False).needs_revision assert make_review(units_consistent=False).needs_revision
# --- Grund jeder Korrekturrunde im Log (vorher nur „Probleme gefunden“) ---
def test_revision_reasons_name_every_trigger():
from checks.review import needs_work, revision_reasons
from core.model_schema import CheckIssue, CheckSeverity
fehlend = {"unclear_point": "Einkaufspreis nicht beziffert", "assumption": "-", "alternative": "-", "impact": "-"}
review = make_review(matches=False, verdict="weaker_than_task", missing=[fehlend])
issues = [CheckIssue(severity=CheckSeverity.ERROR, code="MIXED_OBJECTIVE_UNITS", message="Risiko dimensionslos"),
CheckIssue(severity=CheckSeverity.WARNING, code="MISSING_DATA", message="kein Wert für p")]
gruende = revision_reasons(review, issues)
assert needs_work(review, issues)
assert any(g.startswith("Zielgröße passt nicht") for g in gruende)
assert any("weaker_than_task" in g for g in gruende)
assert any("Einkaufspreis nicht beziffert" in g for g in gruende)
assert any("MIXED_OBJECTIVE_UNITS" in g for g in gruende)
assert not any("MISSING_DATA" in g for g in gruende) # löst keine Runde aus, gehört nicht in den Grund
def test_clean_state_has_no_revision_reasons():
from checks.review import revision_reasons
assert revision_reasons(make_review(), []) == []
def test_review_prompt_allows_both_forms_of_decision_independent_costs():
# wagner_whitin_dijkstra: Einkaufspreis ohne Zahl – je 1–2 Korrekturrunden, weil Regel 4 beide Formen beanstanden konnte
from pathlib import Path
prompt = (Path(__file__).parent.parent / "checks" / "Pruef-Prompt.txt").read_text(encoding="utf-8")
assert "entscheidungsunabhängige Terme" in prompt and "Verlange keine Umstellung" in prompt

View file

@ -811,3 +811,26 @@ def test_scenario_without_result_is_a_finding_unless_proven_unsolvable(wert, sta
assert bool(befunde) == befund assert bool(befunde) == befund
if befund: if befund:
assert "Ausgewogene Skalarisierung" in befunde[0] and "Startlösung" in befunde[0] assert "Ausgewogene Skalarisierung" in befunde[0] and "Startlösung" in befunde[0]
@pytest.mark.parametrize("modus, bwrap_da, erwartet", [
("auto", False, "bubblewrap ist nicht installiert"), # früher stillschweigend ungeschützt
("aus", True, "abgeschaltet"),
("auto", True, None),
("bwrap", False, None), # Lauf wird ohnehin verweigert
])
def test_sandbox_warning_names_unprotected_runs(monkeypatch, modus, bwrap_da, erwartet):
from solving import solver_stage
monkeypatch.setattr(solver_stage, "SANDBOX", modus)
monkeypatch.setattr(solver_stage.shutil, "which", lambda name: "/usr/bin/bwrap" if bwrap_da else None)
warnung = solver_stage.sandbox_warning()
assert (warnung is None) if erwartet is None else (erwartet in warnung)
def test_unprotected_run_carries_the_warning_into_the_result(monkeypatch):
from solving import solver_stage
from test_model_checks import make_spec
monkeypatch.setattr(solver_stage, "SANDBOX", "aus")
code = 'def main():\n """Test."""\n report("optimal", 1.0, {})\n\n\nif __name__ == "__main__":\n main()\n'
run = solver_stage._check_and_run(code, 30, make_spec())
assert any("Sandbox abgeschaltet" in w for w in run.warnings)