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:
parent
8dd5073cd1
commit
6ecfdf6ae0
4 changed files with 244 additions and 60 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()) == []
|
||||
|
|
|
|||
|
|
@ -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)]]}],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue