diff --git a/changelog.d/crfb-tob-scenario.added.md b/changelog.d/crfb-tob-scenario.added.md new file mode 100644 index 00000000..5b12a142 --- /dev/null +++ b/changelog.d/crfb-tob-scenario.added.md @@ -0,0 +1 @@ +Added a named CRFB Post-OBBBA long-run TOB scenario contract for validating the target source, hash, and Trustees threshold mode. diff --git a/src/policyengine/scenarios/__init__.py b/src/policyengine/scenarios/__init__.py new file mode 100644 index 00000000..586b45f4 --- /dev/null +++ b/src/policyengine/scenarios/__init__.py @@ -0,0 +1,19 @@ +from policyengine.scenarios.crfb import ( + CRFB_POST_OBBBA_TOB_SCENARIO_ID, + CRFB_POST_OBBBA_TOB_TARGET_SHA256, + CRFB_POST_OBBBA_TOB_TARGET_SOURCE, + TRUSTEES_CORE_THRESHOLD_LAW_MODE, + CRFBPostOBBBATOBContract, + crfb_post_obbba_tob_contract, + validate_crfb_post_obbba_tob_metadata, +) + +__all__ = [ + "CRFBPostOBBBATOBContract", + "CRFB_POST_OBBBA_TOB_SCENARIO_ID", + "CRFB_POST_OBBBA_TOB_TARGET_SHA256", + "CRFB_POST_OBBBA_TOB_TARGET_SOURCE", + "TRUSTEES_CORE_THRESHOLD_LAW_MODE", + "crfb_post_obbba_tob_contract", + "validate_crfb_post_obbba_tob_metadata", +] diff --git a/src/policyengine/scenarios/crfb.py b/src/policyengine/scenarios/crfb.py new file mode 100644 index 00000000..73f33c47 --- /dev/null +++ b/src/policyengine/scenarios/crfb.py @@ -0,0 +1,76 @@ +"""Named CRFB long-run TOB scenario contracts.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from pydantic import BaseModel, Field + +CRFB_POST_OBBBA_TOB_SCENARIO_ID = "crfb_post_obbba_tob_75y" +CRFB_POST_OBBBA_TOB_TARGET_ID = "post_obbba_calibrated_tob_75y" +CRFB_POST_OBBBA_TOB_TARGET_SOURCE = "oact_2025_08_05_provisional" +CRFB_POST_OBBBA_TOB_TARGET_SHA256 = ( + "75e9dbe6a30680670713089ceed3eb10d7ef597b88c4317d0b39571e25f381f3" +) +TRUSTEES_CORE_THRESHOLD_LAW_MODE = "trustees-2025-core-thresholds-v1" + + +def _expected_artifact_contract() -> dict[str, Any]: + return { + "must_consume_baseline_sha256": CRFB_POST_OBBBA_TOB_TARGET_SHA256, + "must_expose_scenario_id": CRFB_POST_OBBBA_TOB_SCENARIO_ID, + "reject_raw_current_law_substitution": True, + } + + +class CRFBPostOBBBATOBContract(BaseModel): + """Reproducibility contract for the CRFB Post-OBBBA TOB target source.""" + + scenario_id: str = CRFB_POST_OBBBA_TOB_SCENARIO_ID + calibration_target_id: str = CRFB_POST_OBBBA_TOB_TARGET_ID + target_source: str = CRFB_POST_OBBBA_TOB_TARGET_SOURCE + target_sha256: str = CRFB_POST_OBBBA_TOB_TARGET_SHA256 + baseline_kind: str = "calibration_target" + not_law: bool = True + law_mode: str = TRUSTEES_CORE_THRESHOLD_LAW_MODE + artifact_contract: dict[str, Any] = Field( + default_factory=_expected_artifact_contract + ) + + def validate_metadata(self, metadata: Mapping[str, Any]) -> dict[str, Any]: + errors = [] + checks = { + "name": self.target_source, + "scenario_id": self.scenario_id, + "calibration_target_id": self.calibration_target_id, + "baseline_kind": self.baseline_kind, + "not_law": self.not_law, + "law_mode": self.law_mode, + "sha256": self.target_sha256, + } + for key, expected in checks.items(): + actual = metadata.get(key) + if actual != expected: + errors.append(f"{key}={actual!r}, expected {expected!r}") + + artifact_contract = metadata.get("artifact_contract") + if artifact_contract != self.artifact_contract: + errors.append( + "artifact_contract does not match the CRFB Post-OBBBA TOB contract" + ) + + if errors: + raise ValueError( + "Invalid CRFB Post-OBBBA TOB scenario metadata: " + "; ".join(errors) + ) + return dict(metadata) + + +def crfb_post_obbba_tob_contract() -> CRFBPostOBBBATOBContract: + return CRFBPostOBBBATOBContract() + + +def validate_crfb_post_obbba_tob_metadata( + metadata: Mapping[str, Any], +) -> dict[str, Any]: + return crfb_post_obbba_tob_contract().validate_metadata(metadata) diff --git a/tests/test_crfb_scenario_contract.py b/tests/test_crfb_scenario_contract.py new file mode 100644 index 00000000..030b3145 --- /dev/null +++ b/tests/test_crfb_scenario_contract.py @@ -0,0 +1,64 @@ +import pytest + +from policyengine.scenarios import ( + CRFB_POST_OBBBA_TOB_SCENARIO_ID, + CRFB_POST_OBBBA_TOB_TARGET_SHA256, + CRFB_POST_OBBBA_TOB_TARGET_SOURCE, + TRUSTEES_CORE_THRESHOLD_LAW_MODE, + crfb_post_obbba_tob_contract, + validate_crfb_post_obbba_tob_metadata, +) + + +def _valid_metadata(**overrides): + metadata = { + "name": CRFB_POST_OBBBA_TOB_TARGET_SOURCE, + "scenario_id": CRFB_POST_OBBBA_TOB_SCENARIO_ID, + "calibration_target_id": "post_obbba_calibrated_tob_75y", + "baseline_kind": "calibration_target", + "not_law": True, + "law_mode": TRUSTEES_CORE_THRESHOLD_LAW_MODE, + "sha256": CRFB_POST_OBBBA_TOB_TARGET_SHA256, + "artifact_contract": { + "must_consume_baseline_sha256": CRFB_POST_OBBBA_TOB_TARGET_SHA256, + "must_expose_scenario_id": CRFB_POST_OBBBA_TOB_SCENARIO_ID, + "reject_raw_current_law_substitution": True, + }, + } + metadata.update(overrides) + return metadata + + +def test_crfb_post_obbba_tob_contract_names_target_and_law_mode(): + contract = crfb_post_obbba_tob_contract() + + assert contract.scenario_id == CRFB_POST_OBBBA_TOB_SCENARIO_ID + assert contract.target_source == CRFB_POST_OBBBA_TOB_TARGET_SOURCE + assert contract.target_sha256 == CRFB_POST_OBBBA_TOB_TARGET_SHA256 + assert contract.law_mode == TRUSTEES_CORE_THRESHOLD_LAW_MODE + assert contract.not_law is True + + +def test_validate_crfb_post_obbba_tob_metadata_accepts_exact_contract(): + metadata = _valid_metadata() + + assert validate_crfb_post_obbba_tob_metadata(metadata) == metadata + + +def test_validate_crfb_post_obbba_tob_metadata_rejects_current_law_substitution(): + metadata = _valid_metadata( + name="trustees_2025_current_law", + baseline_kind="current_law_comparator", + not_law=False, + sha256="e059aa9fba806b260a399b8a6a18b892a6363ba12ee00fe21ab109d09dff0ec4", + ) + + with pytest.raises(ValueError, match="trustees_2025_current_law"): + validate_crfb_post_obbba_tob_metadata(metadata) + + +def test_validate_crfb_post_obbba_tob_metadata_rejects_hash_mismatch(): + metadata = _valid_metadata(sha256="0" * 64) + + with pytest.raises(ValueError, match="sha256"): + validate_crfb_post_obbba_tob_metadata(metadata)