text_to_solver_program/tests/test_feasibility.py
dschlueter 915c79c1c8 fix: Index-Platzhalter im Namensvertrag, englische Einheiten, fett gesetzte Teilfragen
- Routing-Prompt: Restriktionsnamen als Muster beschrieben statt kopierbarer Beispiele,
  kein Indexbuchstabe im identifier (Weiter_ab_Woche_k löste in 3/3 Läufen eine
  unnötige Solver-Reparatur aus)
- check_constraint_coverage: identifier …_k passt zu …_2, nur ohne direkten Treffer
  (nonneg_x/nonneg_y bleiben getrennt)
- check_answer_units: englische Einheitenwörter (tons, hours, euros …); vorher strich
  eine Reparatur die richtige Einheit aus einer Antwort auf eine englische Aufgabe
- QUESTION_PATTERN erkennt **Teil 1:** Text

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:50:27 +02:00

172 lines
8.7 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
def test_relaxation_bound_equal_up_to_rounding_is_no_warning():
# beobachtet (Portfolio Nr. 2): 0.13896862211718014 gegen 0.13896862211718017
run = sens_run([], objective=0.13896862211718017, relaxation=0.13896862211718014)
assert check_sensitivity(run, "maximize") == []
def test_index_placeholder_in_identifier_matches_the_written_out_index():
# beobachtet (20260917-164603): Spezifikation „Weiter_ab_Woche_k“, Programm „Weiter_ab_Woche_2“ – je eine Reparatur
from test_model_checks import make_spec
spec = make_spec(constraints=[
{"identifier": "Start_Woche_1", "latex_formula": "x = 1", "description": "-", "is_hard_constraint": True},
{"identifier": "Weiter_ab_Woche_k", "latex_formula": "x = y", "description": "-", "is_hard_constraint": True}])
assert check_constraint_coverage([Named("Start_Woche_1"), Named("Weiter_ab_Woche_2"), Named("Weiter_ab_Woche_3")], spec) == []
# ein anderer Name mit gleichem Anfang bleibt ein Befund
assert check_constraint_coverage([Named("Start_Woche_1"), Named("Weiter_ab_Monat_2")], spec)
def test_real_names_ending_in_a_letter_are_not_placeholders():
# Rückrechnung hard_transport_lagerwahl: nonneg_x und nonneg_y sind zwei echte Restriktionen
from test_model_checks import make_spec
spec = make_spec(constraints=[
{"identifier": "nonneg_x", "latex_formula": "x >= 0", "description": "-", "is_hard_constraint": True},
{"identifier": "nonneg_y", "latex_formula": "y >= 0", "description": "-", "is_hard_constraint": True}])
assert check_constraint_coverage([Named("nonneg_x"), Named("nonneg_y")], spec) == []
assert check_constraint_coverage([Named("nonneg_x")], spec) == ["Restriktion 'nonneg_y' aus der Spezifikation fehlt im Programm"]