portfolio-optimiser/tests/spikes/test_c_validator.py
Kjell Tore Guttormsen 37547fe292
refactor(examples): replace sector-specific example material with generic, fictitious examples
The context sets, the packaged knowledge bases and the example bundles are
replaced by one fictitious example set about IT operations in an invented
organisation: three context sets (serverrom-2027, driftsavtale-2027 and the
two-base drift-og-avtale-2027), two synthetic knowledge bases under
src/portfolio_optimiser/data/kunnskapsbaser and two example bundles under
src/portfolio_optimiser/data/bundles. Numbers, codes and structural values in
tests and fixtures are kept; names, ids and wording change. Dated measurement
documents that only recorded runs on the replaced material are deleted.

Gate figures measured on the new set are not comparable with earlier ones.
The exclusion gate from the previous commit is green: 0 tracked files hit
outside the shared/ subtree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:04:21 +02:00

102 lines
3.4 KiB
Python

"""Spike C tests — blocking hybrid validator (B1). Crafted IR, deterministic.
Checks: structural block of an out-of-range proposal (P10/P50/P90 only exist on the
validated path); a valid proposal yields P10 <= P50 <= P90; Monte Carlo reproducibility;
self-repair capped. Pattern: tests/test_reference_domain.py + pytest.raises.
"""
import pytest
from pydantic import ValidationError
from portfolio_optimiser.reference_domain import load_reference_projects
from spikes.c_validator import (
Rejection,
SavingsProposal,
ValidatedProposal,
proposal_for,
self_repair,
validate_proposal,
)
_ASSUMPTIONS = {"05.2": (200.0, 230.0), "03.1": (290.0, 330.0)}
@pytest.fixture(scope="module")
def project():
return load_reference_projects()[0] # KONTOR-IT-E1
def _valid(project) -> SavingsProposal:
# Affected total ~1.48M NOK; 30% feasible cap ~0.44M. Claim 200k is comfortably feasible.
return proposal_for(
project, ["05.2", "03.1"], claimed_saving_nok=200_000, assumptions=_ASSUMPTIONS
)
def _out_of_range(project) -> SavingsProposal:
# Constructs fine (800k <= 1.48M total) but far exceeds the ~0.44M feasible cap.
return proposal_for(
project, ["05.2", "03.1"], claimed_saving_nok=800_000, assumptions=_ASSUMPTIONS
)
def test_out_of_range_is_structurally_blocked(project) -> None:
result = validate_proposal(_out_of_range(project))
assert isinstance(result, Rejection)
# A Rejection carries no percentiles -> it can never be consumed as validated.
with pytest.raises(AttributeError):
_ = result.p50 # type: ignore[attr-defined]
def test_valid_proposal_yields_ordered_percentiles(project) -> None:
result = validate_proposal(_valid(project))
assert isinstance(result, ValidatedProposal)
# P10 <= P50 <= P90
assert result.p10 <= result.p50 <= result.p90
assert result.nominal_feasible > 0 # the CBC solve produced a feasible bound
def test_monte_carlo_is_reproducible(project) -> None:
a = validate_proposal(_valid(project))
b = validate_proposal(_valid(project))
assert isinstance(a, ValidatedProposal) and isinstance(b, ValidatedProposal)
assert (a.p10, a.p50, a.p90) == (b.p10, b.p50, b.p90)
def test_pydantic_blocks_claim_above_affected_total(project) -> None:
with pytest.raises(ValidationError):
proposal_for(project, ["05.2"], claimed_saving_nok=99_000_000)
def test_pydantic_blocks_negative_quantity() -> None:
with pytest.raises(ValidationError):
SavingsProposal(
project_id="X",
measure="bad",
affected_items=[{"code": "A", "quantity": -1, "unit_cost": 10}],
claimed_saving_nok=1,
)
def test_self_repair_stops_at_max_attempts(project) -> None:
calls = {"n": 0}
def always_bad(_attempt: int) -> SavingsProposal:
calls["n"] += 1
return _out_of_range(project)
result = self_repair(always_bad, max_attempts=3)
assert isinstance(result, Rejection)
assert calls["n"] == 3 # bounded — never exceeds max_attempts
def test_self_repair_returns_on_first_valid(project) -> None:
calls = {"n": 0}
def valid_on_second(attempt: int) -> SavingsProposal:
calls["n"] += 1
return _valid(project) if attempt >= 2 else _out_of_range(project)
result = self_repair(valid_on_second, max_attempts=3)
assert isinstance(result, ValidatedProposal)
assert calls["n"] == 2