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>
102 lines
3.4 KiB
Python
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
|