"""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)