From 5d3464c5f0fcb0c79377bac7abeb76d3cc0f5ce8 Mon Sep 17 00:00:00 2001 From: subfleet Date: Sun, 11 Oct 2026 12:52:56 -0400 Subject: [PATCH 1/3] Keep reform stop dates through policy normalisation _normalise_period_key turned a legacy start.stop key into its start date, so the wrapper read every bounded API reform as open-ended and it never expired. Keys now keep their stop dates. A policy with any interval key is compiled into a policyengine.core Policy whose ParameterValues carry explicit end_dates, because the pinned wrapper's dict compiler rejects interval keys. Paths are still validated by the wrapper's compile_reform. Policies without interval keys keep going through the wrapper's own dict compilation. Malformed keys and intervals that end before they start raise. Current-law baselines keep their deterministic artifact ids; reforms never reuse them. Fixes #740. Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 1 + .../simulation_runtime.py | 65 ++++- .../tests/test_baseline_artifacts.py | 84 +++++++ .../tests/test_simulation_output_builder.py | 8 +- .../tests/test_simulation_runtime_policy.py | 236 ++++++++++++++++++ 5 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py 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..eec5f5972 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 + dates = [datetime.strptime(part, "%Y-%m-%d") for part in parts] + if any(date.date().isoformat() != part for date, part in zip(dates, parts)): + raise ValueError + 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,42 @@ 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 _resolve_dataset_reference(country: str) -> str: return resolve_bundle_dataset_name(country, None) @@ -631,6 +685,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/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_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..26dc6d231 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py @@ -0,0 +1,236 @@ +"""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-4-06", + "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) From e307617e32104c82f46d500217f592031b580aa0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:09:15 -0400 Subject: [PATCH 2/3] Check the API hands the wrapper every Budget reform contract case unchanged Once the installed wrapper ships the UK Budget reform contract, every case sent in the legacy start.stop form must reach Simulation(policy=...) as the same parameter values the contract's wrapper.policy route compiles. Unknown paths are rejected by both. Skipped until then. Co-Authored-By: Claude Opus 5.5 --- .../test_budget_reform_contract_route.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 projects/policyengine-simulation-executor/tests/test_budget_reform_contract_route.py 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) From ece6e16ea710ec7c697067284d4f59bb58149744 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:46:33 -0400 Subject: [PATCH 3/3] Compile interval policies for Stage 12 aggregation, and accept unpadded dates Review fixes (GPT-6.1 Sol): - Stage 12 aggregation handed the normalised policy dict straight to the wrapper, whose pinned dict compiler rejects interval keys. Every path that hands a request policy to the wrapper now goes through _runtime_policy (normalise, then compile intervals). - Policy keys accept unpadded months and days, as the wrapper's strptime always has. Co-Authored-By: Claude Opus 5.5 --- .../simulation_runtime.py | 16 +++++- .../stage12_runtime/aggregation.py | 8 ++- .../tests/test_simulation_runtime_policy.py | 19 ++++++- .../tests/test_stage12_runtime.py | 55 +++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) 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 eec5f5972..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 @@ -188,9 +188,9 @@ def _parse_policy_period(period_key: Any) -> tuple[datetime, datetime | None]: 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] - if any(date.date().isoformat() != part for date, part in zip(dates, parts)): - raise ValueError start = dates[0] stop = dates[1] if len(dates) == 2 else None if stop is not None and stop < start: @@ -259,6 +259,18 @@ def _compile_interval_policy(policy, *, country_module, year: int): 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) 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_simulation_runtime_policy.py b/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py index 26dc6d231..68f9ed1d4 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_runtime_policy.py @@ -116,7 +116,6 @@ def test_unknown_parameter_retains_wrapper_error_and_close_match_hint(uk): [ "2027", "2027-02-30", - "2027-4-06", "2027-04-06.", ".2028-04-05", "2027-04-06.2028-04-05.extra", @@ -234,3 +233,21 @@ def recording_compiler(reform, *, year, model_version): 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"] = {}