diff --git a/changelog_entry.yaml b/changelog_entry.yaml index f11ae4b5b..066e85eb1 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,6 +1,7 @@ - bump: patch changes: fixed: + - Preserved bounded reform stop dates in UK and US simulations while retaining current-law baseline artifact reuse - Resolved PolicyEngine-managed regional datasets using bundled data versions instead of release-manifest commit hashes when loading from GCS - Added a Modal app-release bundle backfill for missing data artifact revisions - Ensured Stage 12-only deployments run authenticated candidate tests and verify every required deployment phase before reporting success diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py index a6cf23a3c..5018b0f79 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py @@ -14,6 +14,7 @@ import tempfile import time from dataclasses import dataclass +from datetime import datetime from importlib import import_module from typing import Any, Iterator @@ -180,12 +181,29 @@ def _parse_year(params: dict[str, Any]) -> int: return int(value) -def _normalise_period_key(period_key: Any) -> str: - """Convert legacy ``start.stop`` period keys to v4 effective dates.""" +def _parse_policy_period(period_key: Any) -> tuple[datetime, datetime | None]: + """Parse effective dates or inclusive ``start.stop`` policy periods.""" text = str(period_key) parts = text.split(".") - if len(parts) > 1 and len(parts[0]) == 10: - return parts[0] + try: + if len(parts) not in {1, 2}: + raise ValueError + # The wrapper has always parsed dates with strptime, which also takes + # unpadded months and days ("2027-4-6"); accept what it accepted. + dates = [datetime.strptime(part, "%Y-%m-%d") for part in parts] + start = dates[0] + stop = dates[1] if len(dates) == 2 else None + if stop is not None and stop < start: + raise ValueError + except ValueError: + raise ValueError(f"Invalid policy period key: {text!r}") from None + return start, stop + + +def _normalise_period_key(period_key: Any) -> str: + """Validate policy period keys without discarding their stop dates.""" + text = str(period_key) + _parse_policy_period(text) return text @@ -205,6 +223,54 @@ def _normalise_policy(policy: dict[str, Any] | None) -> dict[str, Any] | None: return normalised +def _compile_interval_policy(policy, *, country_module, year: int): + """Use public wrapper values for intervals its dict compiler cannot parse.""" + if not policy or not any( + isinstance(value, dict) and any("." in str(key) for key in value) + for value in policy.values() + ): + return policy + + from policyengine.core import ParameterValue, Policy + from policyengine.core.parameter import Parameter + from policyengine.tax_benefit_models.common.reform import compile_reform + + compiled = compile_reform(policy, year=year, model_version=country_module.model) + parameter_values = [] + for path, periods in compiled.items(): + dated_values = [ + (*_parse_policy_period(key), value) for key, value in periods.items() + ] + for start, stop, value in sorted(dated_values, key=lambda item: item[0]): + # Match the wrapper's scalar type selection and effective dates. + data_type = type(value) if isinstance(value, (int, float, bool)) else float + parameter_values.append( + ParameterValue( + parameter=Parameter( + name=path, + tax_benefit_model_version=country_module.model, + data_type=data_type, + ), + start_date=start, + end_date=stop, + value=value, + ) + ) + return Policy(name="Reform", parameter_values=parameter_values) + + +def _runtime_policy(policy, *, country_module, year: int): + """A request's policy as the wrapper must receive it. + + Normalises the period keys, then compiles a policy with interval keys + into wrapper parameter values (see ``_compile_interval_policy``). Every + path that hands a request policy to the wrapper goes through this. + """ + return _compile_interval_policy( + _normalise_policy(policy), country_module=country_module, year=year + ) + + def _resolve_dataset_reference(country: str) -> str: return resolve_bundle_dataset_name(country, None) @@ -631,6 +697,9 @@ def _build_simulation( spm_kwargs = {"spm": selection} if selection is not None else {} country = params.get("country", "us") country_module = _country_module(country) + policy = _compile_interval_policy( + policy, country_module=country_module, year=_parse_year(params) + ) simulation_id = deterministic_baseline_id( params, country=country, diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/aggregation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/aggregation.py index ee92f8fbf..a9dadb23a 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/aggregation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/aggregation.py @@ -138,7 +138,7 @@ def build_aggregate_report( ) from policyengine_simulation_executor.simulation_runtime import ( _country_module, - _normalise_policy, + _runtime_policy, ) country = report.baseline.geography.country @@ -160,7 +160,11 @@ def stand_in(dataset, policy: dict[str, JsonValue]): simulation = PrecomputedSimulation( dataset=dataset, tax_benefit_model_version=country_module.model, - policy=_normalise_policy(policy), + # Interval keys need compiling: the wrapper's dict compiler + # rejects them. + policy=_runtime_policy( + policy, country_module=country_module, year=dataset.year + ), ) simulation.output_dataset = dataset return simulation diff --git a/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py b/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py index f3db74ed3..6f9fdfaf2 100644 --- a/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py +++ b/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py @@ -85,6 +85,36 @@ def test_region_group_qualifies_with_cache_key(self, collected): def test_reform_policy_disqualifies(self, collected): assert self._id(collected, policy={"gov.x": 1}) is None + @pytest.mark.parametrize("kind", ["bounded_dict", "bounded_policy", "scalar"]) + def test_reforms_keep_current_law_artifact_identity_separate(self, collected, kind): + from policyengine_simulation_executor import simulation_runtime as sr + + path = "gov.hmrc.income_tax.rates.uk[0].rate" + policy = {path: {"2027-04-06.2028-04-05": 0.21}} + if kind == "bounded_policy": + policy = sr._compile_interval_policy( + policy, country_module=sr._country_module("uk"), year=2027 + ) + elif kind == "scalar": + policy = {path: 0.21} + + assert self._id(collected) == "bl1-deadbeefdeadbeef" + baseline_calls = list(collected) + assert self._id(collected, policy=policy) is None + assert ( + ba.qualifying_baseline_identity( + {"scope": "macro"}, + country="us", + dataset_is_default=True, + policy=policy, + region_code="us", + scoping_strategy=None, + year=2026, + ) + is None + ) + assert collected == baseline_calls + @pytest.mark.parametrize("scope", [None, "", "household"]) def test_non_macro_scope_disqualifies(self, collected, scope): params = {} if scope is None else {"scope": scope} @@ -424,6 +454,60 @@ def test_non_qualifying_uses_plain_simulation(self, wired): assert wired.id_kwargs["policy"] == {"gov.x": 1} +def test_bounded_reform_build_keeps_current_law_artifact_reuse(monkeypatch): + from policyengine import core as policyengine_core + + from policyengine_simulation_executor import simulation_runtime as sr + from policyengine_simulation_executor import spm as executor_spm + + installed_capability = executor_spm.runtime_spm_capability() + monkeypatch.setattr( + executor_spm, "runtime_spm_capability", lambda: installed_capability + ) + monkeypatch.setattr( + ba, + "ArtifactBaselineSimulation", + lambda **kwargs: SimpleNamespace(kind="artifact", **kwargs), + ) + monkeypatch.setattr( + policyengine_core, + "Simulation", + lambda **kwargs: SimpleNamespace(kind="plain", **kwargs), + ) + identities = [] + + def collect_identity(*args, **kwargs): + identities.append((args, kwargs)) + return SimpleNamespace(simulation_id="bl1-current-law") + + monkeypatch.setattr(ak, "collect_baseline_identity", collect_identity) + kwargs = { + "dataset": "dataset", + "dataset_selection": sr.DatasetSelection("default", "default", True), + "region_code": "uk", + } + params = {"country": "uk", "scope": "macro", "time_period": 2027} + baseline = sr._build_simulation(params, policy=None, **kwargs) + reform = sr._build_simulation( + params, + policy={ + "gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06.2028-04-05": 0.21} + }, + **kwargs, + ) + + assert baseline.kind == "artifact" + assert baseline.id == "bl1-current-law" + assert baseline.policy is None + assert reform.kind == "plain" + assert isinstance(reform.policy, policyengine_core.Policy) + assert ( + reform.policy.parameter_values[0].end_date.isoformat() == "2028-04-05T00:00:00" + ) + assert not hasattr(reform, "id") + assert len(identities) == 1 + + class TestIdentityErrorContract: """The stated split: the writer path fails loud, the reader degrades.""" diff --git a/projects/policyengine-simulation-executor/tests/test_budget_reform_contract_route.py b/projects/policyengine-simulation-executor/tests/test_budget_reform_contract_route.py new file mode 100644 index 000000000..e0724e85d --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_budget_reform_contract_route.py @@ -0,0 +1,62 @@ +"""The API route of the UK Budget reform contract. + +The contract (policyengine.py's +``policyengine.tax_benefit_models.uk.budget_reform_contract``) checks its +cases through ``Simulation(policy=...)`` in-process and leaves the API route +to this service. A request reaches that same wrapper route after policy +normalisation, so the API route holds when normalisation hands the wrapper +the same parameter values for every case: legacy ``start.stop`` keys in, +identical effective dates and end dates out. This test runs once the +installed wrapper ships the contract. +""" + +import pytest + +contract = pytest.importorskip( + "policyengine.tax_benefit_models.uk.budget_reform_contract" +) + +from policyengine.tax_benefit_models.common.reform import ( # noqa: E402 + compile_reform_to_policy, +) +from policyengine.utils.parametric_reforms import ( # noqa: E402 + reform_dict_from_parameter_values, +) + +from policyengine_simulation_executor.simulation_runtime import ( # noqa: E402 + _compile_interval_policy, + _country_module, + _normalise_policy, +) + +CASES = [ + case + for case in contract.CASES + if case.changes and (case.routes is None or "wrapper.policy" in case.routes) +] + + +@pytest.mark.parametrize("case", CASES, ids=[case.id for case in CASES]) +def test_the_api_hands_the_wrapper_each_case_unchanged(case): + uk = _country_module("uk") + year = min(check.year for check in case.checks) if case.checks else 2027 + # The app and gateway send the legacy start.stop form. + request = contract.country_reform_dict(case.changes) + wrapper_form = contract.represent(case, "wrapper.policy") + if case.expect_error is not None: + with pytest.raises(ValueError, match=case.expect_error): + _compile_interval_policy( + _normalise_policy(request), country_module=uk, year=year + ) + with pytest.raises(ValueError, match=case.expect_error): + compile_reform_to_policy(wrapper_form, year=year, model_version=uk.model) + return + through_api = _compile_interval_policy( + _normalise_policy(request), country_module=uk, year=year + ) + through_wrapper = compile_reform_to_policy( + wrapper_form, year=year, model_version=uk.model + ) + assert reform_dict_from_parameter_values( + through_api.parameter_values + ) == reform_dict_from_parameter_values(through_wrapper.parameter_values) diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py index 4f0f9e51d..2e200af21 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py @@ -1038,9 +1038,9 @@ def test_builder_serializes_cliff_impact_when_requested(monkeypatch): } -def test_normalise_policy_converts_legacy_period_range_keys(): +def test_normalise_policy_preserves_legacy_period_range_keys(): assert _normalise_policy({"gov.test.parameter": {"2026-01-01.2100-12-31": 1}}) == { - "gov.test.parameter": {"2026-01-01": 1} + "gov.test.parameter": {"2026-01-01.2100-12-31": 1} } @@ -1108,8 +1108,8 @@ def serialize(self): ) _assert_macro_result(result, selection) - assert build_calls[0][2] == {"gov.test.parameter": {"2026-01-01": 1}} - assert build_calls[1][2] == {"gov.test.parameter": {"2026-01-01": 2}} + assert build_calls[0][2] == {"gov.test.parameter": {"2026-01-01.2100-12-31": 1}} + assert build_calls[1][2] == {"gov.test.parameter": {"2026-01-01.2100-12-31": 2}} assert build_calls[0][3] is None assert build_calls[1][3] is None assert builder_calls == [ diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py b/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py new file mode 100644 index 000000000..68f9ed1d4 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py @@ -0,0 +1,253 @@ +"""Policy compilation against the installed wrapper, without running a model.""" + +from datetime import datetime + +import pytest +from policyengine.core import Dataset, Policy +from policyengine.tax_benefit_models.common import reform as wrapper_reform +from policyengine.utils.parametric_reforms import reform_dict_from_parameter_values + +from policyengine_simulation_executor.simulation_runtime import ( + DEFAULT_YEAR, + DatasetSelection, + _build_simulation, + _compile_interval_policy, + _country_module, + _normalise_policy, + _parse_year, +) + +UK_RATE = "gov.hmrc.income_tax.rates.uk[0].rate" +UK_ALLOWANCE = "gov.hmrc.income_tax.allowances.personal_allowance.amount" +US_SALT_CAP = "gov.irs.deductions.itemized.salt_and_real_estate.cap.JOINT" + + +@pytest.fixture(scope="module") +def uk(): + country = _country_module("uk") + assert UK_RATE in country.model.parameters_by_name + assert UK_ALLOWANCE in country.model.parameters_by_name + return country + + +def _parameter_value_contents(policy): + """Compare effective values without wrapper-generated IDs and timestamps.""" + return [ + ( + value.parameter.name, + value.parameter.tax_benefit_model_version.id, + value.parameter.data_type, + value.start_date, + value.end_date, + value.value, + ) + for value in policy.parameter_values + ] + + +@pytest.mark.parametrize("stop", ["2028-04-05", "2100-12-31", "2027-04-06"]) +def test_uk_interval_policy_preserves_inclusive_stop_date(uk, stop): + key = f"2027-04-06.{stop}" + raw = {UK_RATE: {key: 0.21}} + + policy = _compile_interval_policy( + _normalise_policy(raw), country_module=uk, year=2027 + ) + + assert isinstance(policy, Policy) + assert len(policy.parameter_values) == 1 + value = policy.parameter_values[0] + assert value.parameter.name == UK_RATE + assert value.parameter.tax_benefit_model_version is uk.model + assert value.parameter.data_type is float + assert value.start_date == datetime(2027, 4, 6) + assert value.end_date == datetime.strptime(stop, "%Y-%m-%d") + assert value.value == 0.21 + assert reform_dict_from_parameter_values(policy.parameter_values) == raw + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_mixed_effective_dates_and_intervals_are_chronological(uk, reverse): + entries = [ + ("2026-04-06", 0.20), + ("2027-04-06.2028-04-05", 0.21), + ("2029-04-06", 0.22), + ] + if reverse: + entries.reverse() + + policy = _compile_interval_policy( + {UK_RATE: dict(entries)}, country_module=uk, year=2027 + ) + + assert [ + (value.start_date, value.end_date, value.value) + for value in policy.parameter_values + ] == [ + (datetime(2026, 4, 6), None, 0.20), + (datetime(2027, 4, 6), datetime(2028, 4, 5), 0.21), + (datetime(2029, 4, 6), None, 0.22), + ] + assert reform_dict_from_parameter_values(policy.parameter_values) == { + UK_RATE: { + "2026-04-06.2027-04-05": 0.20, + "2027-04-06.2028-04-05": 0.21, + "2029-04-06.2100-12-31": 0.22, + } + } + + +def test_unknown_parameter_retains_wrapper_error_and_close_match_hint(uk): + unknown = "gov.hmrc.income_tax.rates.uk[0].rat" + raw = {unknown: {"2027-04-06.2028-04-05": 0.21}} + + with pytest.raises(ValueError) as expected: + wrapper_reform.compile_reform(raw, year=2027, model_version=uk.model) + with pytest.raises(ValueError) as actual: + _compile_interval_policy(raw, country_module=uk, year=2027) + + assert str(actual.value) == str(expected.value) + assert unknown in str(actual.value) + assert f"did you mean '{UK_RATE}'" in str(actual.value) + + +@pytest.mark.parametrize( + "key", + [ + "2027", + "2027-02-30", + "2027-04-06.", + ".2028-04-05", + "2027-04-06.2028-04-05.extra", + "2028-04-06.2027-04-05", + ], +) +def test_invalid_policy_period_names_key_in_error(uk, key): + raw = {UK_RATE: {"2026-04-06.2027-04-05": 0.20, key: 0.21}} + + with pytest.raises(ValueError) as normalise_error: + _normalise_policy(raw) + with pytest.raises(ValueError) as compile_error: + _compile_interval_policy(raw, country_module=uk, year=2027) + + assert key in str(normalise_error.value) + assert key in str(compile_error.value) + + +@pytest.mark.parametrize( + "period,year", + [ + ({"time_period": "2031", "year": "2030"}, 2031), + ({"year": "2032"}, 2032), + ({}, DEFAULT_YEAR), + ], +) +def test_scalar_in_interval_policy_uses_request_year(uk, period, year): + params = {"country": "uk", "scope": "macro", **period} + raw = { + UK_RATE: {"2027-04-06.2028-04-05": 0.21}, + UK_ALLOWANCE: 13000, + } + # No data is loaded. A different dataset year proves defaulting uses the + # request's parsed year when the executor constructs the interval policy. + dataset = Dataset(name="in-memory", description="Compilation only", year=2025) + + simulation = _build_simulation( + params, + dataset=dataset, + dataset_selection=DatasetSelection("in-memory", "in-memory", False), + policy=raw, + region_code="uk", + ) + + assert _parse_year(params) == year + scalar = next( + value + for value in simulation.policy.parameter_values + if value.parameter.name == UK_ALLOWANCE + ) + assert scalar.start_date == datetime(year, 1, 1) + assert scalar.end_date is None + assert scalar.value == 13000 + assert scalar.parameter.data_type is int + assert reform_dict_from_parameter_values(simulation.policy.parameter_values) == { + UK_RATE: {"2027-04-06.2028-04-05": 0.21}, + UK_ALLOWANCE: {f"{year}-01-01.2100-12-31": 13000}, + } + + +def test_us_interval_policy_uses_same_public_parameter_values(): + us = _country_module("us") + assert US_SALT_CAP in us.model.parameters_by_name + raw = {US_SALT_CAP: {"2027-01-01.2027-12-31": 50000}} + + policy = _compile_interval_policy(raw, country_module=us, year=2027) + + assert isinstance(policy, Policy) + assert len(policy.parameter_values) == 1 + value = policy.parameter_values[0] + assert value.parameter.name == US_SALT_CAP + assert value.parameter.tax_benefit_model_version is us.model + assert value.parameter.data_type is int + assert value.start_date == datetime(2027, 1, 1) + assert value.end_date == datetime(2027, 12, 31) + assert value.value == 50000 + assert reform_dict_from_parameter_values(policy.parameter_values) == raw + + +@pytest.mark.parametrize( + "spec", + [0.21, 1, False, "0.21", {"2027-04-06": 0.21, "2028-04-06": 0.22}], +) +def test_policy_without_intervals_keeps_original_wrapper_compilation( + uk, monkeypatch, spec +): + raw = {UK_RATE: spec} + original_compiler = wrapper_reform.compile_reform_to_policy + expected = original_compiler(raw, year=2027, model_version=uk.model) + calls = [] + + def recording_compiler(reform, *, year, model_version): + calls.append((reform, year, model_version)) + return original_compiler(reform, year=year, model_version=model_version) + + monkeypatch.setattr(wrapper_reform, "compile_reform_to_policy", recording_compiler) + assert _compile_interval_policy(raw, country_module=uk, year=2027) is raw + + simulation = _build_simulation( + {"country": "uk", "scope": "macro", "time_period": "2027"}, + dataset=Dataset(name="in-memory", description="Compilation only", year=2027), + dataset_selection=DatasetSelection("in-memory", "in-memory", False), + policy=raw, + region_code="uk", + ) + + assert len(calls) == 1 + # The wrapper's Simulation validator receives a copy of the dict. + assert calls[0][0] == raw + assert calls[0][1] == 2027 + assert calls[0][2] is uk.model + assert _parameter_value_contents(simulation.policy) == _parameter_value_contents( + expected + ) + assert reform_dict_from_parameter_values( + simulation.policy.parameter_values + ) == reform_dict_from_parameter_values(expected.parameter_values) + + +@pytest.mark.parametrize( + "key, start, stop", + [ + ("2027-4-6", datetime(2027, 4, 6), None), + ("2027-4-6.2028-4-5", datetime(2027, 4, 6), datetime(2028, 4, 5)), + ], +) +def test_unpadded_dates_are_accepted_as_the_wrapper_always_accepted_them( + key, start, stop +): + from policyengine_simulation_executor.simulation_runtime import ( + _parse_policy_period, + ) + + assert _normalise_policy({UK_RATE: {key: 0.21}}) == {UK_RATE: {key: 0.21}} + assert _parse_policy_period(key) == (start, stop) diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_runtime.py b/projects/policyengine-simulation-executor/tests/test_stage12_runtime.py index 909d91a9b..f989c3a90 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_runtime.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_runtime.py @@ -825,6 +825,61 @@ def serialize(self): ) +def test_aggregate_stand_ins_keep_bounded_policy_end_dates(monkeypatch) -> None: + from datetime import datetime + + from policyengine_simulation_executor import simulation_output_builder + + path = "gov.simulation.labor_supply_responses.elasticities.income" + report = _report() + report = report.model_copy( + update={ + "baseline": report.baseline.model_copy( + update={"policy": {path: {"2026-01-01.2026-12-31": 0.05}}} + ), + "reform": report.reform.model_copy( + update={"policy": {path: {"2026-01-01.2027-12-31": 0.1}}} + ), + } + ) + frames = { + entity: pd.DataFrame({f"{entity}_id": [1]}) + for entity in ( + "person", + "marital_unit", + "family", + "spm_unit", + "tax_unit", + "household", + ) + } + observed = {} + + class CapturingBuilder: + def __init__(self, **values): + observed.update(values) + + def serialize(self): + return {"captured": True} + + monkeypatch.setattr( + simulation_output_builder, "SimulationOutputBuilder", CapturingBuilder + ) + artifacts = FakeArtifacts() + + build_aggregate_report( + report=report, + baseline_frames=frames, + reform_frames=frames, + baseline_descriptor=artifacts.add_simulation(report.baseline), + reform_descriptor=artifacts.add_simulation(report.reform), + ) + + for role, stop in (("baseline", "2026-12-31"), ("reform", "2027-12-31")): + (value,) = observed[role].policy.parameter_values + assert value.end_date == datetime.strptime(stop, "%Y-%m-%d"), role + + def test_single_worker_rejects_combined_baseline_and_reform_input() -> None: payload = _planned_simulation(SimulationRole.BASELINE).model_dump(mode="json") payload["baseline"] = {}