Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand All @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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."""

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}


Expand Down Expand Up @@ -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 == [
Expand Down
Loading
Loading