"""Deterministische Zulässigkeitsprüfung: Lösung einsetzen, Schranken, Ganzzahligkeit, Namensvertrag.""" import pytest from model_schema import PipelineResult, Sensitivity, SolverRun, SolverStatus from report import build_document, to_html from 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