TDD from method-spec alone (§3 Step 4, §7, §9), golden.json as the only ground truth: ir.py (construction invariants, fail-fast bundle loader), validator.py (closed-form feasibility bound 0.30·Σ + Monte Carlo seed 20260624/512 samples/inclusive quantiles — reproduces every frozen golden field; Rejection as a distinct unconsumable type), provenance.py (stamp mirroring ONLY the deterministic validator). Mutation controls + seed-detach proof (§11); 45/45 green without an API key; ruff + mypy --strict clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""Provenance-stamp tests (method-spec §9).
|
|
|
|
The stamp is authoritative data, not display metadata: at least one citation,
|
|
the producing model/role, token usage, and ``validator_decision`` — which mirrors
|
|
the DETERMINISTIC VALIDATOR only, stamped from the validator's outcome BEFORE any
|
|
checker override, so the two falsifiers are never conflated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from portfolio_optimiser_claude.ir import SavingsProposal
|
|
from portfolio_optimiser_claude.provenance import (
|
|
Citation,
|
|
Provenance,
|
|
stamp_validator_decision,
|
|
)
|
|
from portfolio_optimiser_claude.validator import Rejection, validate_proposal
|
|
|
|
CITATION: dict[str, Any] = {
|
|
"file": "tiltak-led-retrofit.md",
|
|
"span": "200 lysrorarmaturer (90 W -> 40 W)",
|
|
"snippet": "LED-retrofit av 200 lysrorarmaturer (90 W -> 40 W) i kontorlokaler",
|
|
}
|
|
|
|
|
|
def proposal_claiming(claimed: float) -> SavingsProposal:
|
|
return SavingsProposal(
|
|
project_id="P1",
|
|
measure="test measure",
|
|
affected_items=[{"code": "EL", "quantity": 1000, "unit_cost": 1.0}],
|
|
claimed_saving_nok=claimed,
|
|
)
|
|
|
|
|
|
class TestValidatorDecisionMirror:
|
|
"""§9: validator_decision mirrors ONLY the deterministic validator."""
|
|
|
|
def test_validated_outcome_stamps_validated(self) -> None:
|
|
outcome = validate_proposal(proposal_claiming(200))
|
|
assert stamp_validator_decision(outcome) == "validated"
|
|
|
|
def test_rejection_stamps_rejected(self) -> None:
|
|
outcome = validate_proposal(proposal_claiming(500))
|
|
assert isinstance(outcome, Rejection)
|
|
assert stamp_validator_decision(outcome) == "rejected"
|
|
|
|
|
|
class TestStampShape:
|
|
"""§9: at least one citation; decisions use the validator vocabulary; real usage."""
|
|
|
|
def build(self, **overrides: Any) -> Provenance:
|
|
kwargs: dict[str, Any] = {
|
|
"citations": [CITATION],
|
|
"role": "proposer",
|
|
"validator_decision": "validated",
|
|
"tokens_used": 0,
|
|
}
|
|
kwargs.update(overrides)
|
|
return Provenance(**kwargs)
|
|
|
|
def test_valid_stamp_constructs(self) -> None:
|
|
stamp = self.build()
|
|
assert stamp.citations[0] == Citation(**CITATION)
|
|
assert stamp.role == "proposer"
|
|
|
|
def test_zero_citations_rejected(self) -> None:
|
|
# A run whose context yields no citable content MUST fail fast (§9).
|
|
with pytest.raises(ValidationError):
|
|
self.build(citations=[])
|
|
|
|
def test_model_falls_back_to_neutral_unknown(self) -> None:
|
|
# Never a fabricated model name — the fallback is the neutral 'unknown' (§9).
|
|
assert self.build().model == "unknown"
|
|
|
|
def test_checker_vocabulary_rejected(self) -> None:
|
|
# 'approved' is checker/expert vocabulary (§4) — validator_decision speaks
|
|
# the validator's language only: validated | rejected.
|
|
with pytest.raises(ValidationError):
|
|
self.build(validator_decision="approved")
|
|
|
|
def test_negative_token_usage_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
self.build(tokens_used=-1)
|