portfolio-optimiser-claude/tests/test_provenance.py
Kjell Tore Guttormsen 1e1b7e4506 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
2026-07-03 06:27:40 +02:00

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)