feat(validator): S6 — deterministic backbone: typed IR, golden-frozen validator, provenance stamp
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
This commit is contained in:
parent
4efb72943c
commit
1e1b7e4506
7 changed files with 529 additions and 0 deletions
88
tests/test_provenance.py
Normal file
88
tests/test_provenance.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue