text_to_solver_program/tests/test_feasibility.py
dschlueter 7459690bf5 refactor: Pipeline-Stufen in Ordner mit ihren Prompts
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>
2026-09-17 12:11:51 +02:00

145 lines
6.9 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.

"""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