text_to_solver_program/tests/test_pipeline.py
dschlueter 6ecfdf6ae0 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>
2026-09-18 13:24:46 +02:00

738 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""run_pipeline ohne LLM: Neustart bei Fremdproblem und Kennzeichnung unzuverlässiger Ergebnisse."""
from benchmark.benchmark import result_correct
from core.model_schema import (
AssumptionReview,
LLMUsage,
OptimizationModelSpecification,
SolverCode,
SolverStatus,
SubQuestion,
TaskUnderstanding,
)
from TextToModel import run_pipeline
from test_model_checks import make_spec
from test_review import make_review
from test_script_export import STRUCTURED_CODE
# Standardverstaendnis: eine Frage, keine Zahlen - reicht fuer Tests, die das Verstehen nicht pruefen
EINFACHES_VERSTAENDNIS = TaskUnderstanding(
is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)])
class FakeSession:
"""Liefert vorgegebene Antworten je Antwort-Typ in fester Reihenfolge."""
def __init__(self, specs, reviews, codes=(), understandings=None, readings=()):
from core.model_schema import FigureReading
self.queues = {
OptimizationModelSpecification: list(specs),
AssumptionReview: list(reviews),
SolverCode: list(codes),
TaskUnderstanding: list(understandings if understandings is not None else []),
FigureReading: list(readings),
}
self.contents = [] # (Stufe, Inhalt der letzten Nachricht) – prüft, wer Bilder zu sehen bekommt
self.usage = LLMUsage(model="fake", provider="fake")
self.calls = {kind: 0 for kind in self.queues}
self.steps = []
self.tasks = []
self.letzter_prompt = ""
def overrides(self):
return {}
def create(self, response_model, messages, step="unbenannt"):
self.letzter_prompt = messages[-1]["content"]
self.contents.append((step, messages[-1]["content"]))
if step == "Extraktion":
self.tasks.append(messages[-1]["content"])
self.calls[response_model] += 1
self.usage.calls += 1
self.steps.append(step)
warteschlange = self.queues[response_model]
if response_model is TaskUnderstanding and not warteschlange:
return EINFACHES_VERSTAENDNIS # Standard, solange ein Test nichts anderes vorgibt
return warteschlange.pop(0)
HALLUCINATED = make_spec(executive_summary="Produktionsplanung mit Drehen und Fräsen") # Fremdproblem
GOOD = make_spec(executive_summary="Rohstoffeinkauf mit minimalen Kosten")
MISMATCH = make_review(matches=False)
OK = make_review()
def test_clean_first_attempt_needs_no_restart():
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1)
assert result.reliable and result.restarts == 0 and result.revisions == 0
assert result.solver_run.status == SolverStatus.OPTIMAL
def test_restart_recovers_from_hallucinated_problem():
# Versuch 1: Fremdproblem, beide Korrekturrunden erfolglos; Neustart liefert das richtige Modell
session = FakeSession(
specs=[HALLUCINATED, HALLUCINATED, HALLUCINATED, GOOD],
reviews=[MISMATCH, MISMATCH, MISMATCH, OK],
codes=[SolverCode(code=STRUCTURED_CODE)],
)
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1)
assert result.restarts == 1
assert result.reliable and result.reliability_issues == []
assert result.specification.executive_summary == GOOD.executive_summary
assert result.solver_run.status == SolverStatus.OPTIMAL and result.solver_run.objective_value == 9
def test_persistent_mismatch_is_unreliable_and_not_solved():
# beobachtet im Benchmark: Fremdproblem wurde als "optimal, 2550" ausgegeben
session = FakeSession(specs=[HALLUCINATED] * 6, reviews=[MISMATCH] * 6)
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1)
assert result.restarts == 1
assert not result.reliable
assert any("Zielfunktion passt nicht" in issue for issue in result.reliability_issues)
assert result.solver_run is None and result.solver_code is None
assert session.calls[SolverCode] == 0 # kein Solver-Code für ein falsches Problem
assert not result_correct(result.solver_run, {"objective_value": 9, "tolerance": 1e-6})
def test_better_earlier_attempt_wins_over_worse_restart():
# Neustart ist schlechter (Zielgröße falsch) als Versuch 1 (nur Check-Error) -> Versuch 1 verwenden
stack_error = make_spec(executive_summary="richtig, aber Stack falsch", problem_class="Quadratic Programming (QP)")
session = FakeSession(
specs=[stack_error, stack_error, stack_error, HALLUCINATED, HALLUCINATED, HALLUCINATED],
reviews=[OK, OK, OK, MISMATCH, MISMATCH, MISMATCH],
codes=[SolverCode(code=STRUCTURED_CODE)],
)
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1)
assert result.specification.executive_summary == "richtig, aber Stack falsch"
assert not result.reliable # STACK_MISMATCH bleibt bestehen
assert any("STACK_MISMATCH" in issue for issue in result.reliability_issues)
assert result.solver_run is not None # Zielgröße passt -> Solver läuft, Ergebnis aber als unzuverlässig markiert
def test_no_restart_when_disabled():
session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3)
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0)
assert result.restarts == 0 and not result.reliable
def test_report_shows_prominent_warning_for_unreliable_result():
from reporting.report import build_document, to_html, to_markdown
session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3)
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0)
doc = build_document(result)
assert doc[1][0] == "warn" # direkt unter der Überschrift
assert '<div class="warn">⚠ Ergebnis unzuverlässig' in to_html(doc)
assert "> **⚠ Ergebnis unzuverlässig" in to_markdown(doc)
assert "Der Solver wurde nicht gestartet." in doc[1][1]
def test_report_shows_the_rejected_reading_of_each_assumption():
from reporting.report import build_document, to_markdown
spec = make_spec(open_assumptions=[{
"unclear_point": "„aufeinanderfolgend“",
"assumption": "Kalendertage, auch über das Wochenende",
"alternative": "Nur Werktage; verworfen, weil der Text durchgehenden Betrieb nennt",
"impact": "Restriktion Ruhezeit greift auch am Wochenende",
}])
session = FakeSession([spec], [OK], [SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, solve_model=False)
markdown = to_markdown(build_document(result))
assert "Verworfene Lesart" in markdown
assert "verworfen, weil der Text durchgehenden Betrieb nennt" in markdown
def test_pipeline_labels_every_llm_call_for_the_timing_breakdown():
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)])
run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1)
assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Solver-Code"]
def test_revision_and_repair_steps_are_labelled():
session = FakeSession(specs=[HALLUCINATED, GOOD], reviews=[MISMATCH, OK],
codes=[SolverCode(code="x = 1"), SolverCode(code=STRUCTURED_CODE)])
run_pipeline("Aufgabe", session=session, max_revisions=1, max_restarts=0)
assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Korrektur", "Prüfung", "Solver-Code", "Solver-Reparatur"]
# --- Zulässigkeitsprüfung im Zusammenspiel ---
VIOLATING_CODE = '''
def solve_model():
"""Liefert eine Lösung, die das Budget um 8 Cent überschreitet (beobachteter Fall)."""
return 100000.08
def main():
"""Meldet die Lösung samt Restriktionen zur Nachprüfung."""
invested = solve_model()
report("optimal", 12041.74, {"x": invested},
constraints=[{"name": "Bedarf", "lhs": invested, "sense": "<=", "rhs": 100000.0}],
variables=[{"name": "x", "value": invested, "var_type": "continuous", "lb": 0}])
if __name__ == "__main__":
main()
'''
def test_infeasible_solution_triggers_repair_and_is_marked_unreliable():
session = FakeSession([GOOD], [OK], codes=[SolverCode(code=VIOLATING_CODE)] * 3)
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=2)
assert session.calls[SolverCode] == 3 # Erstversuch + 2 Reparaturen
assert result.solver_run.feasibility and "Bedarf" in result.solver_run.feasibility[0]
assert not result.reliable
assert any("Zulässigkeitsprüfung" in issue for issue in result.reliability_issues)
def test_feasible_solution_stays_reliable():
session = FakeSession([GOOD], [OK], codes=[SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert result.solver_run.feasibility == []
assert result.reliable and session.calls[SolverCode] == 1
# --- Gegenprobe bei "unlösbar" ---
INFEASIBLE_CODE = '''
def main():
"""Meldet das Modell als unlösbar."""
report("infeasible", None, {})
if __name__ == "__main__":
main()
'''
def test_infeasible_is_rechecked_and_can_be_refuted():
# beobachtet bei qwen3-235b: "unlösbar" war ein Modellierungsfehler, die Aufgabe ist lösbar
session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK],
codes=[SolverCode(code=INFEASIBLE_CODE), SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert result.solver_run.status == SolverStatus.OPTIMAL and result.solver_run.objective_value == 9
assert not result.infeasible_confirmed and result.restarts == 1
assert "HINWEIS" in session.tasks[1] and "unabhängige Modellierung" in session.tasks[1]
def test_infeasible_confirmed_by_second_modelling():
session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK],
codes=[SolverCode(code=INFEASIBLE_CODE)] * 2)
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert result.solver_run.status == SolverStatus.INFEASIBLE
assert result.infeasible_confirmed and result.reliable
def test_infeasible_without_recheck_is_flagged(monkeypatch):
import TextToModel
monkeypatch.setattr(TextToModel, "INFEASIBLE_RECHECK", 0)
session = FakeSession([GOOD], [OK], codes=[SolverCode(code=INFEASIBLE_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert not result.reliable and any("ohne Gegenprobe" in i for i in result.reliability_issues)
# --- Wiedereinstieg ab einer Stufe (continue_pipeline / CLI --von) ---
def stored_result(tmp_path, spec=GOOD, review=OK):
"""Ergebnis eines früheren Laufs als Datei – Grundlage für --von."""
session = FakeSession([spec], [review], [SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Rohstoffeinkauf", session=session, max_revisions=0, max_restarts=0, solve_model=False)
path = tmp_path / "ergebnis.json"
path.write_text(result.model_dump_json(indent=2), encoding="utf-8")
return path
def test_continue_from_solver_skips_extraction_and_review(tmp_path):
from TextToModel import continue_pipeline
path = stored_result(tmp_path)
session = FakeSession([], [], [SolverCode(code=STRUCTURED_CODE)])
result = continue_pipeline(str(path), "solver", session=session)
assert session.steps == ["Solver-Code"] # kein Extraktions-, kein Prüfaufruf
assert result.specification.executive_summary == GOOD.executive_summary
assert result.task == "Rohstoffeinkauf" # Aufgabentext kommt aus dem Ergebnis
assert result.solver_run.status == SolverStatus.OPTIMAL
def test_continue_from_review_keeps_specification_but_checks_again(tmp_path):
from TextToModel import continue_pipeline
path = stored_result(tmp_path)
session = FakeSession([], [OK], [SolverCode(code=STRUCTURED_CODE)])
result = continue_pipeline(str(path), "pruefung", session=session)
assert session.steps == ["Prüfung", "Solver-Code"] # Extraktion entfällt
assert result.specification.executive_summary == GOOD.executive_summary
def test_continue_from_review_can_still_revise(tmp_path):
from TextToModel import continue_pipeline
path = stored_result(tmp_path)
session = FakeSession([GOOD], [MISMATCH, OK], [SolverCode(code=STRUCTURED_CODE)])
result = continue_pipeline(str(path), "pruefung", session=session, max_revisions=1)
assert session.steps == ["Prüfung", "Korrektur", "Prüfung", "Solver-Code"]
assert result.revisions == 1
def test_continue_from_solver_refuses_when_objective_does_not_match(tmp_path):
# gespeicherte Zielgrößen-Abweichung bleibt bindend: kein Solver, kein LLM-Aufruf
from TextToModel import continue_pipeline
path = stored_result(tmp_path, review=MISMATCH)
session = FakeSession([], [], [])
result = continue_pipeline(str(path), "solver", session=session)
assert session.steps == []
assert result.solver_run is None and not result.reliable
def test_unknown_stage_is_rejected(tmp_path):
import pytest
from TextToModel import continue_pipeline
path = stored_result(tmp_path)
with pytest.raises(ValueError, match="Unbekannte Stufe"):
continue_pipeline(str(path), "extraktion", session=FakeSession([], [], []))
def test_warnings_reach_the_revision_prompt():
# Warnungen loesen keine Runde aus, muessen aber mitkorrigiert werden, wenn ohnehin eine stattfindet
from core.model_schema import CheckIssue, CheckSeverity
from modeling.extraction import revise_model
session = FakeSession([GOOD], [], [])
revise_model(session, "Aufgabe", GOOD, OK, issues=[
CheckIssue(severity=CheckSeverity.WARNING, code="UNEXPLAINED_JARGON", message="Fachbegriffe: Fluss"),
CheckIssue(severity=CheckSeverity.ERROR, code="STACK_MISMATCH", message="passt nicht"),
])
prompt = session.letzter_prompt
assert "STACK_MISMATCH" in prompt.split("HINWEISE")[0] # Fehler im Fehlerteil
assert "UNEXPLAINED_JARGON" in prompt.split("HINWEISE")[1] # Warnung im Hinweisteil
# --- Benchmark haelt fest, womit gemessen wurde ---
def test_benchmark_records_the_prompt_state():
from benchmark.benchmark import PROMPT_FILES, prompt_state
stand = prompt_state()
assert {"prompts", "commit", "dirty"} <= set(stand)
assert len(stand["prompts"]) == 12 and stand["commit"]
assert "modeling/Routing-und-Extraktions-Prompt.txt" in PROMPT_FILES
assert "core/model_schema.py" in PROMPT_FILES # Feldbeschreibungen wirken wie Prompt-Anweisungen
assert "understanding/Verstehen-Prompt.txt" in PROMPT_FILES
def test_deviating_settings_are_recorded(monkeypatch):
# Ohne die Umgebung liesse sich ein A/B-Lauf (Stufe abgeschaltet, anderes Modell) nicht zuordnen
from benchmark import benchmark
benchmark.prompt_state.cache_clear()
monkeypatch.setenv("UNDERSTAND_RETRIES", "-1")
monkeypatch.setenv("LLM_MODEL_SOLVER", "billig/modell")
stand = benchmark.prompt_state()
assert stand["config"] == {"UNDERSTAND_RETRIES": "-1", "LLM_MODEL_SOLVER": "billig/modell"}
benchmark.prompt_state.cache_clear()
def test_prompt_digest_changes_with_the_prompt(tmp_path):
from benchmark.benchmark import prompt_digest
a, b = tmp_path / "eins.txt", tmp_path / "zwei.txt"
a.write_text("Regel A"), b.write_text("Regel B")
vorher = prompt_digest([a, b])
assert prompt_digest([a, b]) == vorher # gleicher Inhalt, gleiche Kennung
b.write_text("Regel B, nun schaerfer")
assert prompt_digest([a, b]) != vorher # eine geaenderte Regel aendert die Kennung
assert prompt_digest([b, a]) != prompt_digest([a, b]) # Reihenfolge ist Teil der Kennung
def test_unexplained_jargon_triggers_a_revision_but_stays_reliable():
# Erklaeren ist Kernziel: die Warnung ist eine Runde wert, macht das Ergebnis aber nicht unzuverlaessig
from checks.model_checks import check_spec
unerklaert = make_spec(
executive_summary="Der Betrieb entscheidet ueber Mengen.",
constraints=[{"identifier": "Flusserhaltung", "latex_formula": "\\sum_j x_j = 1", "description": "-"}])
assert "UNEXPLAINED_JARGON" in {i.code for i in check_spec(unerklaert)}
session = FakeSession(specs=[unerklaert, GOOD], reviews=[OK, OK], codes=[SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=1, max_restarts=0)
assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Korrektur", "Prüfung", "Solver-Code"]
assert result.revisions == 1
assert result.reliable # Warnung, kein Fehler
def test_clean_model_still_needs_no_revision():
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0)
assert result.revisions == 0
# --- Verstehensstufe im Ablauf ---
def test_understanding_runs_first_and_is_kept_in_the_result():
from core.model_schema import Fact
verstanden = TaskUnderstanding(
is_optimization_task=True, reason="Kostenminimierung", ignored_numbers=[],
sub_questions=[SubQuestion(id="a", quote="Wie viel?", kind="rechnen", needs_solver=True)],
facts=[Fact(value="12", unit="€", meaning="Deckungsbeitrag je Stueck", quote="12 € je Stueck")])
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[verstanden])
result = run_pipeline("Der Deckungsbeitrag ist 12 € je Stueck.", session=session,
max_revisions=0, max_restarts=0)
assert session.steps[0] == "Verstehen"
assert result.understanding.facts[0].value == "12"
assert [q.id for q in result.understanding.sub_questions] == ["a"]
def test_incomplete_understanding_is_asked_again():
# erste Antwort uebersieht die Zahl, zweite erfasst sie
from core.model_schema import Fact
luecke = TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)])
vollstaendig = luecke.model_copy(update={
"facts": [Fact(value="12", unit="€", meaning="Deckungsbeitrag", quote="12 € je Stueck")]})
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)],
understandings=[luecke, vollstaendig])
result = run_pipeline("12 € je Stueck.", session=session, max_revisions=0, max_restarts=0)
assert session.steps[:2] == ["Verstehen", "Verstehen"] # genau ein Nachfassen
assert result.understanding.facts[0].value == "12"
def test_report_shows_questions_and_numbers():
from core.model_schema import Fact
from reporting.report import build_document, to_markdown
verstanden = TaskUnderstanding(
is_optimization_task=True, reason="-",
sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Problem!", kind="formulieren", needs_solver=False),
SubQuestion(id="b", quote="Loesen Sie es!", kind="rechnen", needs_solver=True)],
facts=[Fact(value="8", unit="€", meaning="Kosten im ersten Jahr", quote="Zeile 0: 8 | 18 | 31")],
ignored_numbers=[])
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[verstanden])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
markdown = to_markdown(build_document(result))
assert "Gestellte Fragen" in markdown and "Formulieren Sie das Problem!" in markdown
assert "Gegebene Zahlen" in markdown and "Kosten im ersten Jahr" in markdown
def test_report_puts_the_answers_before_the_numbers():
from reporting.report import build_document, to_markdown
code = '''
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."},
{"question_id": "b", "text": "Die minimalen Kosten betragen 9 Geldeinheiten."}])
if __name__ == "__main__":
main()
'''
verstanden = TaskUnderstanding(
is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Modell!", kind="formulieren", needs_solver=False),
SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)])
session = FakeSession([GOOD], [OK], [SolverCode(code=code)], understandings=[verstanden])
result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0)
markdown = to_markdown(build_document(result))
assert "Antworten auf die Aufgabe" in markdown
assert markdown.index("Formulieren Sie das Modell!") < markdown.index("| Status |")
assert result.solver_run.warnings == [] # Abnahme ohne Befund
def test_unanswered_sub_question_is_a_warning_not_a_wrong_number():
code = '''
def main():
"""Beantwortet nur die zweite Frage."""
report("optimal", 9.0, {"x": 1.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": "b", "text": "Die minimalen Kosten betragen 9 Geldeinheiten."}])
if __name__ == "__main__":
main()
'''
verstanden = TaskUnderstanding(
is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id="a", quote="Zeichnen Sie den Graphen!", kind="zeichnen", needs_solver=False),
SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)])
session = FakeSession([GOOD], [OK], [SolverCode(code=code)] * 3, understandings=[verstanden])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert any("Teilfragen ohne Antwort" in w for w in result.solver_run.warnings)
assert result.solver_run.objective_value == 9.0 # die Zahl bleibt richtig
def test_report_shows_data_profile_and_scenarios(tmp_path):
from reporting.report import build_document, to_markdown
datei = tmp_path / "werte.csv"
datei.write_text("name,menge\nA,3\nB,4\n")
code = '''
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",
"data_override": {"faktor": 2.0}, "objective_value": 7.0}])
if __name__ == "__main__":
main()
'''
session = FakeSession([GOOD], [OK], [SolverCode(code=code)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, data_files=[datei])
markdown = to_markdown(build_document(result))
assert "Beigelegte Daten" in markdown and "werte.csv (2 Zeilen)" in markdown
assert "Verglichene Konfigurationen" in markdown and "Doppelt" in markdown
assert result.data_files[0].rows == 2
assert result.solver_run.scenarios[0].verified_value == 7.0 # ohne LLM nachgerechnet
# --- Benchmark misst "Aufgabe erfuellt", nicht nur "Zahl richtig" ---
def test_benchmark_counts_unanswered_questions_as_not_fulfilled(monkeypatch):
from benchmark import benchmark
from core.model_schema import Answer, SubQuestion, TaskUnderstanding
def ergebnis(antworten, warnungen=()):
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[TaskUnderstanding(
is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id=f, quote="-", kind="rechnen", needs_solver=True) for f in ("a", "b")])])
r = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
r.solver_run.answers = [Answer(question_id=k, text="-") for k in antworten]
r.solver_run.warnings = list(warnungen)
return r
fall = {"name": "test", "task": "Aufgabe", "expected": {"objective_value": 9, "tolerance": 1e-6}}
for antworten, warnungen, erwartet in [
(["a", "b"], [], True), # alles beantwortet
(["b"], ["Teilfragen ohne Antwort: a"], False), # eine Frage offen
(["a", "b"], ["Antwort zu 'b' nennt Zahlen …"], False), # unbelegte Zahl
]:
monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: ergebnis(antworten, warnungen))
zeile = benchmark.run_case(fall, "fake", 1)
assert zeile["objective_ok"] is True # die Zahl stimmt immer
assert zeile["task_ok"] is erwartet, (antworten, warnungen)
def test_benchmark_case_data_files_are_collected(tmp_path, monkeypatch):
from benchmark import benchmark
fall = tmp_path / "cases" / "mit_daten"
fall.mkdir(parents=True)
(fall / "task.txt").write_text("Aufgabe")
(fall / "expected.json").write_text('{"objective_value": 1, "tolerance": 1e-6}')
(fall / "werte.csv").write_text("a,b\n1,2\n")
monkeypatch.setattr(benchmark, "CASES_DIR", tmp_path / "cases")
faelle = benchmark.load_cases()
assert [p.split("/")[-1] for p in faelle[0]["data_files"]] == ["werte.csv"]
# Spezifikation ohne Kostendaten, Programm erfindet sie per Zufallsgenerator (Muster "Portfolio Nr. 2")
OHNE_DATEN = make_spec(parameters=[
{"symbol": "c_j", "dimension": "Vektor", "unit": "€/t", "description": "Kosten", "extracted_values": ""},
{"symbol": "b", "dimension": "Skalar", "unit": "t", "description": "Bedarf", "extracted_values": "4"}])
ERFUNDENE_DATEN = STRUCTURED_CODE.replace(
"COSTS = [3, 2]", "import numpy as np\nCOSTS = [float(v) for v in np.random.default_rng(0).integers(2, 4, 2)]")
def test_undeclared_generated_data_is_repaired():
session = FakeSession([OHNE_DATEN], [OK], [
SolverCode(code=ERFUNDENE_DATEN),
SolverCode(code=ERFUNDENE_DATEN, data_assumptions=["c_j: zufällig erzeugt (Seed 0), keine Daten im Text"])])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1)
assert session.calls[SolverCode] == 2 # der Befund hat eine Reparatur ausgelöst
assert result.reliable and result.solver_run.undeclared_data == []
def test_persistently_undeclared_data_is_unreliable():
# Nachmessung "Portfolio Nr. 2": data_assumptions leer, Antwort behauptete geladene Kursdaten
session = FakeSession([OHNE_DATEN], [OK], [SolverCode(code=ERFUNDENE_DATEN)] * 2)
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1)
assert not result.reliable
assert any(p.startswith("Datenherkunft") for p in result.reliability_issues)
# Beobachtet: eine reine Gleichung (x! - 2x^2 + 1337 = 3629937) wurde nur mit Log-Warnung weiter modelliert
KEINE_AUFGABE = TaskUnderstanding(
is_optimization_task=False, reason="Eine Gleichung soll gelöst werden; es gibt nichts zu entscheiden.",
facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id="1", quote="x! - 2x**2 + 1337 = 3629937", kind="rechnen", needs_solver=True)])
def test_non_optimization_task_is_rejected_without_modeling(tmp_path):
from reporting.report import write_report
session = FakeSession(specs=[], reviews=[], understandings=[KEINE_AUFGABE])
result = run_pipeline("x! - 2x**2 + 1337 = 3629937", session=session)
assert result.rejected and not result.reliable
assert result.specification is None and result.solver_run is None
assert session.steps == ["Verstehen"] # kein Nachfassen, keine Extraktion, kein Solver
assert "Keine Optimierungsaufgabe" in result.reliability_issues[0]
for endung in ("md", "html"):
ziel = tmp_path / f"abgelehnt.{endung}"
write_report(result, str(ziel))
text = ziel.read_text(encoding="utf-8")
assert "Aufgabe abgelehnt" in text and "nichts zu entscheiden" in text
def test_rejected_result_cannot_be_continued_and_counts_as_failure(tmp_path, monkeypatch):
import pytest
from benchmark import benchmark
from TextToModel import continue_pipeline
session = FakeSession(specs=[], reviews=[], understandings=[KEINE_AUFGABE])
result = run_pipeline("x! = 120", session=session)
gespeichert = tmp_path / "abgelehnt.json"
gespeichert.write_text(result.model_dump_json())
with pytest.raises(ValueError, match="abgelehnt"):
continue_pipeline(str(gespeichert), "solver", session=session)
monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: result)
zeile = benchmark.run_case({"name": "gleichung", "task": "x! = 120", "expected": {}}, "fake", 1)
assert zeile["solver_status"] == "abgelehnt" and zeile["task_ok"] is False
benchmark.print_table([zeile]) # die Ergebnistabelle verkraftet die Zeile
def _mit_szenario(wert):
return STRUCTURED_CODE.replace(
" )\n\n\nif __name__",
' scenarios=[{"name": "Ausgewogen", "description": "-", "data_override": {"faktor": 2}, '
f'"objective_value": {wert}}}],\n )\n\n\nif __name__')
def test_scenario_without_result_is_repaired():
# beobachtet (Portfolio Nr. 2): Zeitlimit ohne Lösung wurde als „keine Lösung" gemeldet
session = FakeSession([GOOD], [OK], [SolverCode(code=_mit_szenario(None)), SolverCode(code=_mit_szenario(8.0))])
result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0, solver_retries=1)
assert session.calls[SolverCode] == 2 # der Befund hat eine Reparatur ausgelöst
assert result.reliable and result.solver_run.unsolved_scenarios == []
def test_persistent_scenario_without_result_is_unreliable():
session = FakeSession([GOOD], [OK], [SolverCode(code=_mit_szenario(None))] * 2)
result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0, solver_retries=1)
assert not result.reliable
assert any(p.startswith("Szenario ohne Ergebnis") for p in result.reliability_issues)
# --- Nebenpfade: Verstehensstufe aus, Gegenprobe und --von mit vollem Kontext ---
def test_pipeline_runs_with_understanding_stage_disabled(monkeypatch):
# UNDERSTAND_RETRIES=-1 war der A/B-Arm der Verstehensstufe; das Ablehnungstor griff dann auf None zu
import TextToModel
monkeypatch.setattr(TextToModel, "UNDERSTAND_RETRIES", -1)
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert result.understanding is None and not result.rejected
assert session.steps[0] == "Extraktion"
assert result.solver_run.status == SolverStatus.OPTIMAL
def test_infeasible_recheck_keeps_understanding_and_data_files(tmp_path, monkeypatch):
# Ohne Datendateien fand das Programm der Gegenprobe seine CSV nicht, ohne Verständnis fehlten die Antworten
import TextToModel
aufrufe = []
echtes_solve = TextToModel.solve
def protokoll(*args, **kwargs):
aufrufe.append(kwargs)
return echtes_solve(*args, **kwargs)
monkeypatch.setattr(TextToModel, "solve", protokoll)
csv = tmp_path / "werte.csv"
csv.write_text("a,b\n1,2\n", encoding="utf-8")
session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK],
codes=[SolverCode(code=INFEASIBLE_CODE), SolverCode(code=STRUCTURED_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, data_files=[str(csv)])
assert result.restarts == 1 and len(aufrufe) == 2
gegenprobe = aufrufe[1]
assert gegenprobe["understanding"] is not None
assert list(gegenprobe["data_files"]) == [str(csv)] and "werte.csv" in gegenprobe["data_prompt"]
assert "werte.csv" in session.tasks[1] # auch die neue Extraktion sieht das Datenprofil
def test_continue_from_review_keeps_understanding(tmp_path):
from TextToModel import continue_pipeline
path = stored_result(tmp_path)
session = FakeSession([], [OK], [SolverCode(code=STRUCTURED_CODE)])
result = continue_pipeline(str(path), "pruefung", session=session)
assert result.understanding is not None
assert [q.id for q in result.understanding.sub_questions] == ["1"]
assert "ZU BEANTWORTENDE TEILFRAGEN" in session.letzter_prompt # der Solver bekommt die Teilfragen
def test_review_and_revision_see_the_data_profile():
from checks.review import review_assumptions
from modeling.extraction import revise_model
session = FakeSession([GOOD], [OK], [])
review_assumptions(session, "Aufgabe", GOOD, "BEIGELEGTE DATEN: kurse.csv")
assert "kurse.csv" in session.letzter_prompt
revise_model(session, "Aufgabe", GOOD, OK, data_prompt="BEIGELEGTE DATEN: kurse.csv")
assert "kurse.csv" in session.letzter_prompt
def test_repair_message_names_every_kind_of_finding():
# Vorher erfuhr das LLM nur von der ersten Befundart und verbrauchte für die zweite einen eigenen Versuch
from solving.solver_stage import solve
code = '''
import numpy as np
def main():
"""Verletzt die Restriktion und erzeugt Zahlen per Zufall, ohne sie auszuweisen."""
np.random.seed(0)
report("optimal", 9.0, {"x": 1.0},
constraints=[{"name": "Bedarf", "lhs": 1.0, "sense": ">=", "rhs": 4.0}],
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0, "ub": None}])
if __name__ == "__main__":
main()
'''
session = FakeSession([], [], [SolverCode(code=code), SolverCode(code=code)])
solve(session, "Aufgabe", GOOD, retries=1)
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)