Im Wurzelverzeichnis liegt nur noch TextToModel.py (Ablauf und CLI). Jede Stufe liegt mit Code und Prompt in einem eigenen Ordner: core, understanding, modeling, checks, solving, reporting, data, benchmark; Tests unter tests/, Aufgaben unter tasks/. understand_task, text_to_model/revise_model und review_assumptions/needs_work/select_best sind aus TextToModel.py zu ihren Prompts gewandert, inhaltlich unveraendert. Prompt-Kennung des Benchmarks bleibt 93744636095a. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
6.9 KiB
Python
145 lines
6.9 KiB
Python
"""Deterministische Zulässigkeitsprüfung: Lösung einsetzen, Schranken, Ganzzahligkeit, Namensvertrag."""
|
||
import pytest
|
||
|
||
from core.model_schema import PipelineResult, Sensitivity, SolverRun, SolverStatus
|
||
from reporting.report import build_document, to_html
|
||
from solving.solver_stage import check_constraint_coverage, check_feasibility, check_sensitivity
|
||
from test_model_checks import make_spec
|
||
from test_review import make_review
|
||
|
||
SPEC = make_spec(
|
||
constraints=[{"identifier": "Budget", "latex_formula": "\\sum_j p_j n_j \\le B", "description": "-"},
|
||
{"identifier": "Mindestanzahl", "latex_formula": "\\sum_j y_j \\ge 5", "description": "-"}],
|
||
variables=[{"name": "n_j", "var_type": "integer", "domain": ">= 0", "description": "-"}],
|
||
)
|
||
|
||
|
||
def run_with(constraints=(), variables=(), status=SolverStatus.OPTIMAL):
|
||
return SolverRun(status=status, objective_value=1.0, constraints=list(constraints), variables=list(variables))
|
||
|
||
|
||
BUDGET_OK = {"name": "Budget", "lhs": 99999.99, "sense": "<=", "rhs": 100000.0}
|
||
BUDGET_8_CENT = {"name": "Budget", "lhs": 100000.08, "sense": "<=", "rhs": 100000.0}
|
||
ANZAHL_OK = {"name": "Mindestanzahl", "lhs": 5, "sense": ">=", "rhs": 5}
|
||
|
||
|
||
def test_clean_solution_has_no_findings():
|
||
run = run_with([BUDGET_OK, ANZAHL_OK], [{"name": "n[A02]", "value": 1037, "var_type": "integer", "lb": 0}])
|
||
assert check_feasibility(run, SPEC) == []
|
||
|
||
|
||
def test_eight_cent_budget_violation_is_found():
|
||
# genau der beobachtete Fall: SCIP meldet "optimal", 8 Cent über dem Budget (8e-7 relativ)
|
||
problems = check_feasibility(run_with([BUDGET_8_CENT, ANZAHL_OK]), SPEC)
|
||
assert len(problems) == 1 and "Budget" in problems[0] and "verletzt" in problems[0]
|
||
|
||
|
||
def test_floating_point_noise_is_tolerated():
|
||
noisy = {"name": "Budget", "lhs": 100000.0000000001, "sense": "<=", "rhs": 100000.0}
|
||
assert check_feasibility(run_with([noisy, ANZAHL_OK]), SPEC) == []
|
||
|
||
|
||
@pytest.mark.parametrize("constraint, expected", [
|
||
({"name": "Mindestanzahl", "lhs": 4, "sense": ">=", "rhs": 5}, "Mindestanzahl"),
|
||
({"name": "Budget", "lhs": 99999.0, "sense": "==", "rhs": 100000.0}, "Budget"),
|
||
])
|
||
def test_other_senses_are_checked(constraint, expected):
|
||
problems = check_feasibility(run_with([constraint, BUDGET_OK, ANZAHL_OK][:2] + [constraint]), SPEC)
|
||
assert any(expected in p for p in problems)
|
||
|
||
|
||
@pytest.mark.parametrize("variable, expected", [
|
||
({"name": "n[A02]", "value": 1037.4, "var_type": "integer", "lb": 0}, "nicht ganzzahlig"),
|
||
({"name": "n[A02]", "value": -1, "var_type": "integer", "lb": 0}, "Untergrenze"),
|
||
({"name": "n[A02]", "value": 11, "var_type": "integer", "lb": 0, "ub": 10}, "Obergrenze"),
|
||
({"name": "y[A02]", "value": 2, "var_type": "binary"}, "weder 0 noch 1"),
|
||
])
|
||
def test_variable_violations_are_found(variable, expected):
|
||
problems = check_feasibility(run_with([BUDGET_OK, ANZAHL_OK], [variable]), SPEC)
|
||
assert any(expected in p for p in problems)
|
||
|
||
|
||
def test_missing_constraints_report_is_a_finding():
|
||
assert "keine Restriktionen" in check_feasibility(run_with(), SPEC)[0]
|
||
|
||
|
||
def test_infeasible_run_is_not_checked():
|
||
assert check_feasibility(run_with(status=SolverStatus.INFEASIBLE), SPEC) == []
|
||
|
||
|
||
# --- Namensvertrag ---
|
||
|
||
class Named:
|
||
def __init__(self, name): self.name = name
|
||
|
||
|
||
def test_missing_constraint_in_program_is_found():
|
||
problems = check_constraint_coverage([Named("Budget")], SPEC)
|
||
assert problems == ["Restriktion 'Mindestanzahl' aus der Spezifikation fehlt im Programm"]
|
||
|
||
|
||
def test_invented_constraint_is_found():
|
||
problems = check_constraint_coverage([Named("Budget"), Named("Mindestanzahl"), Named("Erfunden")], SPEC)
|
||
assert any("'Erfunden'" in p and "passt zu keiner" in p for p in problems)
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["Budget", "Budget_A02", "Budget[A02]"])
|
||
def test_indexed_names_are_accepted(name):
|
||
assert check_constraint_coverage([Named(name), Named("Mindestanzahl")], SPEC) == []
|
||
|
||
|
||
# --- Sensitivität (Punkt 3): Plausibilität der Neulösungen ---
|
||
|
||
KAPAZITAET = {"name": "Budget", "lhs": 100000.0, "sense": "<=", "rhs": 100000.0}
|
||
|
||
|
||
def sens_run(sensitivity, objective=12000.0, relaxation=None, constraints=(KAPAZITAET,)):
|
||
return SolverRun(status=SolverStatus.OPTIMAL, objective_value=objective, constraints=list(constraints),
|
||
sensitivity=[Sensitivity.model_validate(s) for s in sensitivity], relaxation_bound=relaxation)
|
||
|
||
|
||
def test_plausible_sensitivity_has_no_warning():
|
||
# Maximierung: mehr Budget (rhs+δ) darf nicht schlechter sein als weniger (rhs−δ)
|
||
run = sens_run([{"name": "Budget", "delta": 1000, "objective_plus": 12100.0, "objective_minus": 11900.0}])
|
||
assert check_sensitivity(run, "maximize") == []
|
||
|
||
|
||
def test_implausible_direction_is_warned():
|
||
run = sens_run([{"name": "Budget", "delta": 1000, "objective_plus": 11900.0, "objective_minus": 12100.0}])
|
||
warnings = check_sensitivity(run, "maximize")
|
||
assert len(warnings) == 1 and "unplausibel" in warnings[0]
|
||
|
||
|
||
def test_greater_equal_constraint_direction_is_handled():
|
||
# Minimierung mit ">=": Lockern heißt rhs senken; niedrigere Kosten dort sind plausibel
|
||
bedarf = {"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}
|
||
run = sens_run([{"name": "Bedarf", "delta": 1, "objective_plus": 11.0, "objective_minus": 9.0}],
|
||
objective=10.0, constraints=(bedarf,))
|
||
assert check_sensitivity(run, "minimize") == []
|
||
falsch = sens_run([{"name": "Bedarf", "delta": 1, "objective_plus": 9.0, "objective_minus": 11.0}],
|
||
objective=10.0, constraints=(bedarf,))
|
||
assert any("unplausibel" in w for w in check_sensitivity(falsch, "minimize"))
|
||
|
||
|
||
def test_unknown_constraint_and_bad_delta_are_warned():
|
||
run = sens_run([{"name": "Gibt es nicht", "delta": 1, "objective_plus": 1.0, "objective_minus": 1.0},
|
||
{"name": "Budget", "delta": 0, "objective_plus": 1.0, "objective_minus": 1.0}])
|
||
warnings = check_sensitivity(run, "maximize")
|
||
assert any("keine solche Restriktion" in w for w in warnings) and any("delta" in w for w in warnings)
|
||
|
||
|
||
def test_relaxation_bound_must_not_be_worse_than_integer_solution():
|
||
gut = sens_run([], objective=12000.0, relaxation=12500.0) # Relaxation ist Schranke nach oben
|
||
schlecht = sens_run([], objective=12000.0, relaxation=11500.0)
|
||
assert check_sensitivity(gut, "maximize") == []
|
||
assert any("Relaxationsschranke" in w for w in check_sensitivity(schlecht, "maximize"))
|
||
|
||
|
||
def test_report_shows_sensitivity_and_integrality_price():
|
||
run = sens_run([{"name": "Budget", "delta": 1000, "objective_plus": 12100.0, "objective_minus": 11900.0,
|
||
"unit": "€ je €"}], relaxation=12500.0)
|
||
result = PipelineResult(task="Aufgabe", specification=make_spec(), review=make_review(), revisions=0,
|
||
solver_run=run)
|
||
page = to_html(build_document(result))
|
||
assert "Sensitivität der bindenden Restriktionen" in page and "Preis der Ganzzahligkeit" in page
|
||
assert "0,1" in page.replace(".", ",") or "0.1" in page # (12100-12000)/1000 = 0.1
|