From 9bad97fee381e997988e5631128f45efd8443b12 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:02:49 +0400 Subject: [PATCH 1/7] test: prepare fixtures for PolicyEngine 6 --- tests/fixtures/libs/simulation_entrypoint.py | 18 +++- tests/fixtures/services/economy_service.py | 15 ++- tests/fixtures/spm.py | 102 ++++++++++++++++++ .../test_calculate_deprecated_inputs.py | 2 +- .../routes/test_calculate_error_statuses.py | 4 +- .../test_household_calculation_service.py | 4 +- 6 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/spm.py diff --git a/tests/fixtures/libs/simulation_entrypoint.py b/tests/fixtures/libs/simulation_entrypoint.py index 9a11b4c90..cdc5b488b 100644 --- a/tests/fixtures/libs/simulation_entrypoint.py +++ b/tests/fixtures/libs/simulation_entrypoint.py @@ -15,6 +15,10 @@ MODAL_EXECUTION_STATUS_COMPLETE, MODAL_EXECUTION_STATUS_FAILED, ) +from tests.fixtures.spm import ( + worker_result_fields, + worker_versions_document, +) # Mock data constants MOCK_MODAL_JOB_ID = "fc-abc123xyz" @@ -45,6 +49,7 @@ "poverty_impact": {"baseline": 0.12, "reform": 0.10}, "budget_impact": {"baseline": 1000, "reform": 1200}, "inequality_impact": {"baseline": 0.45, "reform": 0.42}, + **worker_result_fields(years=["2025"]), } MOCK_POLICYENGINE_BUNDLE = { "model_version": "1.459.0", @@ -56,6 +61,10 @@ ), } MOCK_RESOLVED_APP_NAME = "policyengine-us-1-459-0" +MOCK_WORKER_VERSIONS = worker_versions_document( + app_name=MOCK_RESOLVED_APP_NAME, + country_version="1.459.0", +) MOCK_SUBMIT_RESPONSE_SUCCESS = { "job_id": MOCK_MODAL_JOB_ID, @@ -121,7 +130,14 @@ "startYear": "2026", "endYear": "2028", "windowSize": 3, - "annualImpacts": [], + "annualImpacts": [ + { + "year": year, + **MOCK_SIMULATION_RESULT, + **worker_result_fields(years=[year]), + } + for year in ("2026", "2027", "2028") + ], "totals": {}, }, "error": None, diff --git a/tests/fixtures/services/economy_service.py b/tests/fixtures/services/economy_service.py index cd40425b2..075974457 100644 --- a/tests/fixtures/services/economy_service.py +++ b/tests/fixtures/services/economy_service.py @@ -8,6 +8,11 @@ MODAL_EXECUTION_STATUS_SUBMITTED, ) from policyengine_api.data.v1_models import ReformImpact +from tests.fixtures.spm import ( + options_hash_segment, + worker_result_fields, + worker_spm_capability, +) # Mock data constants MOCK_COUNTRY_ID = "us" @@ -25,9 +30,10 @@ MOCK_DATA_VERSION = "faux-populace-us-2099-test-release" MOCK_LOOKUP_OPTIONS_HASH = ( "[option1=value1&option2=value2" - "&dataset=default" - "&model_version=1.2.3" - "&policyengine_version=3.4.0]" + + options_hash_segment() + + "&dataset=default" + + "&model_version=1.2.3" + + "&policyengine_version=3.4.0]" ) MOCK_OPTIONS_HASH = ( MOCK_LOOKUP_OPTIONS_HASH[:-1] @@ -55,6 +61,7 @@ "poverty_impact": {"baseline": 0.12, "reform": 0.10}, "budget_impact": {"baseline": 1000, "reform": 1200}, "inequality_impact": {"baseline": 0.45, "reform": 0.42}, + **worker_result_fields(years=[MOCK_TIME_PERIOD]), } MOCK_SIM_CONFIG = { @@ -134,6 +141,7 @@ def mock_simulation_entrypoint(): mock_batch_execution = create_mock_budget_window_batch_execution() mock_api._setup_sim_options.return_value = MOCK_SIM_CONFIG + mock_api.get_spm_capability.return_value = worker_spm_capability() mock_api.run.return_value = mock_execution mock_api.resolve_app_name.side_effect = ( lambda country_id, version=None, policyengine_version=None: ( @@ -314,6 +322,7 @@ def mock_simulation_entrypoint_legacy(): mock_execution = create_mock_modal_execution() mock_api.run.return_value = mock_execution + mock_api.get_spm_capability.return_value = None mock_api.resolve_app_name.side_effect = ( lambda country_id, version=None, policyengine_version=None: ( MOCK_RESOLVED_APP_NAME, diff --git a/tests/fixtures/spm.py b/tests/fixtures/spm.py new file mode 100644 index 000000000..8406251d1 --- /dev/null +++ b/tests/fixtures/spm.py @@ -0,0 +1,102 @@ +"""Shared result shapes for the SPM behavior selected by the installed bundle.""" + +from __future__ import annotations + +from policyengine_api import spm +from policyengine_api.constants import POLICYENGINE_VERSION + + +SPM_CONTRACT_VERSION = "canonical-spm-v1" +INSTALLED_SPM_SELECTION = spm.normalize_spm_selection("us", None) + + +def worker_spm_capability(selection: dict | None = None) -> dict | None: + """Return the capability advertised by a worker using this bundle.""" + resolved = INSTALLED_SPM_SELECTION if selection is None else selection + if resolved is None: + return None + return { + "contract_version": SPM_CONTRACT_VERSION, + "defaults": dict(resolved), + } + + +def worker_versions_document( + *, + bundle_version: str = POLICYENGINE_VERSION, + app_name: str = "test-worker-app", + country: str = "us", + country_version: str | None = None, + selection: dict | None = None, +) -> dict: + """Return the gateway registry document for a matching test worker.""" + document: dict = { + "policyengine": {bundle_version: app_name, "latest": bundle_version}, + } + if country_version is not None: + document[country] = { + country_version: app_name, + "latest": country_version, + } + capability = worker_spm_capability(selection) + if capability is not None: + document["spm_capabilities"] = {bundle_version: capability} + return document + + +def spm_receipt(*, years, selection: dict | None = None) -> dict: + """Return one calculation receipt covering the supplied years.""" + resolved = INSTALLED_SPM_SELECTION if selection is None else selection + if resolved is None: + raise ValueError("An SPM receipt requires a resolved selection") + return { + "forecast_id": "test-only", + "forecast_sha256": resolved["forecast_content_sha256"], + "scenario": resolved["scenario"], + "geography_kind": resolved["geography_kind"], + "runtime_versions": {"policyengine-us": "test-only"}, + "years": {str(year): {"status": "forecast"} for year in years}, + "geographies": [], + "composition_method": "classified-inputs", + "storage_method": "formula", + } + + +def worker_result_fields(*, years, selection: dict | None = None) -> dict: + """Return the SPM fields required on an economy worker result.""" + resolved = INSTALLED_SPM_SELECTION if selection is None else selection + if resolved is None: + return {} + receipt = spm_receipt(years=years, selection=resolved) + return { + "spm_config": dict(resolved), + "spm_provenance": { + "baseline": [dict(receipt)], + "reform": [dict(receipt)], + }, + } + + +def household_result_fields(*, years, selection: dict | None = None) -> dict: + """Return the SPM fields required on a household calculation result.""" + resolved = INSTALLED_SPM_SELECTION if selection is None else selection + if resolved is None: + return {} + return { + "spm_config": dict(resolved), + "spm_provenance": spm_receipt(years=years, selection=resolved), + } + + +def resolved_options(options: dict, selection: dict | None = None) -> dict: + """Return request options after applying the installed bundle default.""" + resolved = INSTALLED_SPM_SELECTION if selection is None else selection + if resolved is None: + return dict(options) + return {**options, "spm": dict(resolved)} + + +def options_hash_segment(selection: dict | None = None) -> str: + """Return the part of a sorted options hash contributed by SPM.""" + resolved = INSTALLED_SPM_SELECTION if selection is None else selection + return "" if resolved is None else f"&spm={resolved}" diff --git a/tests/unit/routes/test_calculate_deprecated_inputs.py b/tests/unit/routes/test_calculate_deprecated_inputs.py index aeb0af3e8..b6f837a7f 100644 --- a/tests/unit/routes/test_calculate_deprecated_inputs.py +++ b/tests/unit/routes/test_calculate_deprecated_inputs.py @@ -37,7 +37,7 @@ def __init__(self): }, } - def calculate(self, household, policy): + def calculate(self, household, policy, **_kwargs): self.household = household self.policy = policy return {"household": household, "policy": policy} diff --git a/tests/unit/routes/test_calculate_error_statuses.py b/tests/unit/routes/test_calculate_error_statuses.py index 6f8f6cd19..31ae5a1fa 100644 --- a/tests/unit/routes/test_calculate_error_statuses.py +++ b/tests/unit/routes/test_calculate_error_statuses.py @@ -20,7 +20,7 @@ class ParsingErrorCountry(DummyCountry): - def calculate(self, household, policy): + def calculate(self, household, policy, **_kwargs): raise SituationParsingError( ["people", "you", "employment_income", "2026"], "Can't deal with value: expected type number, received '{}'.", @@ -28,7 +28,7 @@ def calculate(self, household, policy): class CrashingCountry(DummyCountry): - def calculate(self, household, policy): + def calculate(self, household, policy, **_kwargs): raise RuntimeError("engine exploded") diff --git a/tests/unit/services/test_household_calculation_service.py b/tests/unit/services/test_household_calculation_service.py index 0efed99bc..f3176002d 100644 --- a/tests/unit/services/test_household_calculation_service.py +++ b/tests/unit/services/test_household_calculation_service.py @@ -112,7 +112,7 @@ class Country: "parameters": {}, } - def calculate(self, household, policy): + def calculate(self, household, policy, **_kwargs): return CalculationResult( household=household, warnings=("employment_income could not be calculated",), @@ -144,7 +144,7 @@ class Country: "entities": {"person": {"plural": "people", "roles": {}}}, } - def calculate(self, household, policy): + def calculate(self, household, policy, **_kwargs): assert primary.active_scopes == 0 return CalculationResult( household={"people": {"you": {"net_income": {"2026": 42}}}}, From f938a1c6f22b311a2467d2c477a40595130c0db9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:05:50 +0400 Subject: [PATCH 2/7] build: update API v1 to PolicyEngine 6.1.2 --- docker/Dockerfile | 4 +-- pyproject.toml | 8 +++--- tests/unit/test_cloud_run_deploy_scripts.py | 18 ++++++------ uv.lock | 31 ++++++++++----------- 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7b08cd132..9f5a41635 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ FROM python:3.12 # Match the API bundle in pyproject.toml; the bundle updater changes both pins. -# Exact bundled model requirements prevent pip from silently backtracking. -RUN pip install "policyengine[models]==5.2.0" spm-calculator==0.3.1 ipython +# The models extra pins the complete certified runtime package set. +RUN pip install "policyengine[models]==6.1.2" ipython diff --git a/pyproject.toml b/pyproject.toml index 5bf85c01f..416e475ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,10 @@ dependencies = [ "policyengine_canada==0.96.3", "policyengine-ng==0.5.1", "policyengine-il==0.1.0", - "policyengine[models]==5.2.0", - # Cloud Run installs with pip, which does not read uv.lock. Keep the - # calculator compatible with the country model in this bundle. - "spm-calculator==0.3.1", + # The models extra pins Core, US, UK, and the SPM calculator to the exact + # versions certified by this PolicyEngine bundle. Cloud Run installs this + # requirement with pip and does not read uv.lock. + "policyengine[models]==6.1.2", "pydantic", "pymysql", "python-dotenv", diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 601011366..77cb6fc1d 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -515,24 +515,24 @@ def test_cloud_run_dockerfile_runs_startup_with_bash(): assert 'CMD ["/bin/sh", "/app/start.sh"]' not in dockerfile -def test_active_images_pin_spm_calculator_for_the_legacy_country_bundle(): - """Both pip build paths must protect the current bundle's SPM behavior.""" +def test_active_images_install_the_same_policyengine_models_bundle(): + """Both pip build paths use the wrapper's exact certified package set.""" import tomllib project = tomllib.loads((REPO / "pyproject.toml").read_text()) - assert "spm-calculator==0.3.1" in project["project"]["dependencies"] - # The independently published GHCR image does not install this project. + dependencies = project["project"]["dependencies"] generic_image = (REPO / "docker/Dockerfile").read_text() - assert "spm-calculator==0.3.1" in generic_image bundle_pin = next( requirement - for requirement in project["project"]["dependencies"] + for requirement in dependencies if requirement.startswith("policyengine[models]==") ) assert bundle_pin in generic_image - assert "pip install policyengine-core policyengine-uk policyengine-us" not in ( - generic_image - ) + assert not any(item.startswith("spm-calculator") for item in dependencies) + assert "spm-calculator==" not in generic_image + for package in ("policyengine-core", "policyengine-us", "policyengine-uk"): + assert not any(item.startswith(package) for item in dependencies) + assert f"{package}==" not in generic_image def test_deployed_startup_execs_only_the_api_server(): diff --git a/uv.lock b/uv.lock index 64d96a293..79da4a48f 100644 --- a/uv.lock +++ b/uv.lock @@ -2687,7 +2687,7 @@ wheels = [ [[package]] name = "policyengine" -version = "5.2.0" +version = "6.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, @@ -2699,9 +2699,9 @@ dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/2d/5b09f414e26573748f9ae2e448d6f9f94196b7733e8400d727db9ed17868/policyengine-5.2.0.tar.gz", hash = "sha256:c1e7a3a8d7fb23401aedbe8a9fa2d6ed69e24ae9983b6a4a8a0d5112394b0bc4", size = 732330, upload-time = "2026-08-29T17:24:52.814Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/93/1cbdb9324920fe9f9b91db3ea076c2b8ea1e5c3eb986bcc3f601e07cb720/policyengine-6.1.2.tar.gz", hash = "sha256:18eb12c7639105d3369c182babedf52a8aef8b0c0e4aae27181fe97d9a02d1ee", size = 771537, upload-time = "2026-09-27T03:54:52.533Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c5/677144df3800ae41dd587b292bd633c09edd5c998695ca15987c05730bc6/policyengine-5.2.0-py3-none-any.whl", hash = "sha256:e73307f787cf366dc16c080b708c39c28a58b0adf34abe51de5ecd97ecfab154", size = 226102, upload-time = "2026-08-29T17:24:51.377Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/63af579350d76cf85debca243c0360fa6689b50d22bedd89b3425aa7dd5b/policyengine-6.1.2-py3-none-any.whl", hash = "sha256:526e6557fc52b18d671be1726e30bf4eb728124d17372bf27c82c5fcc25813be", size = 252407, upload-time = "2026-09-27T03:54:51.197Z" }, ] [package.optional-dependencies] @@ -2709,11 +2709,12 @@ models = [ { name = "policyengine-core" }, { name = "policyengine-uk" }, { name = "policyengine-us" }, + { name = "spm-calculator" }, ] [[package]] name = "policyengine-api" -version = "3.56.1" +version = "4.2.2" source = { editable = "." } dependencies = [ { name = "a2wsgi" }, @@ -2744,7 +2745,6 @@ dependencies = [ { name = "python-dotenv" }, { name = "redis" }, { name = "rq" }, - { name = "spm-calculator" }, { name = "sqlalchemy" }, { name = "sqlmodel" }, { name = "streamlit" }, @@ -2787,7 +2787,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15,<2" }, { name = "openai" }, { name = "packaging", specifier = ">=24,<27" }, - { name = "policyengine", extras = ["models"], specifier = "==5.2.0" }, + { name = "policyengine", extras = ["models"], specifier = "==6.1.2" }, { name = "policyengine-canada", specifier = "==0.96.3" }, { name = "policyengine-il", specifier = "==0.1.0" }, { name = "policyengine-ng", specifier = "==0.5.1" }, @@ -2801,7 +2801,6 @@ requires-dist = [ { name = "redis" }, { name = "rq" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, - { name = "spm-calculator", specifier = "==0.3.1" }, { name = "sqlalchemy", specifier = ">=2,<3" }, { name = "sqlmodel", specifier = ">=0.0.39,<0.1" }, { name = "streamlit" }, @@ -2841,7 +2840,7 @@ wheels = [ [[package]] name = "policyengine-core" -version = "3.30.1" +version = "3.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath" }, @@ -2861,9 +2860,9 @@ dependencies = [ { name = "standard-imghdr" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/9d/f71da3e348ddc52e058c76b72bbc4b2f20abcf0e7389ecd5aca5d550fd14/policyengine_core-3.30.1.tar.gz", hash = "sha256:a16f29fe51ec01a7be2171386f8bd26b9ba55ffc338adb70adcadafe33d9c55f", size = 500419, upload-time = "2026-07-19T15:50:41.288Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/54/cfd2138584de9cddaa4459bba7fdc381101ca8b8ffd38356a1b256850d5f/policyengine_core-3.32.5.tar.gz", hash = "sha256:f051c269b538fe77ee7868f7cfbc0138551960b1b677a0ad6fdc1a93f1660460", size = 420112, upload-time = "2026-09-10T04:16:48.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/7e/07624d8add8889af1b4af796d36c38b2e2af494b1d78a064fab29e9bea00/policyengine_core-3.30.1-py3-none-any.whl", hash = "sha256:2dbcf5f590a0199a7b7c77fcbbda2ff6bc289f6c6169ca0af3beace35d8b3e63", size = 244846, upload-time = "2026-07-19T15:50:39.651Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72c3707465e9fe77e6f7a55ea64b49a287cbf875b78f6e862d945df26975/policyengine_core-3.32.5-py3-none-any.whl", hash = "sha256:9d8c162d5fe5c784ae16c885da3ffbfc39a536add0e111133144d8edd555935f", size = 245925, upload-time = "2026-09-10T04:16:47.153Z" }, ] [[package]] @@ -2909,7 +2908,7 @@ wheels = [ [[package]] name = "policyengine-us" -version = "1.764.6" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microdf-python" }, @@ -2919,9 +2918,9 @@ dependencies = [ { name = "tables" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/cc/9e65a9586069beab1f02102a23f7a0e6332f1a9c2d590cb9f0e68f9ac987/policyengine_us-1.764.6.tar.gz", hash = "sha256:9817d48abc5b7d690b8abe33047005e5cf40b71654a85a2534bb5e3bcf921f79", size = 11009555, upload-time = "2026-07-06T13:13:07.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/09/763b21371a62635e15e62b53fb696e299f01460907c27313d40f63031eb2/policyengine_us-2.2.1.tar.gz", hash = "sha256:d5300c91f3eae2e47a61f485fdfde91a892b80e95db3e46ff2c4bbd417d76501", size = 11946510, upload-time = "2026-09-15T04:45:50.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5a/0566666f30ad06e416f0cf25b24bdee73acd71897fb2de3c0ac43b59e3e3/policyengine_us-1.764.6-py3-none-any.whl", hash = "sha256:2b2c5c02c26ab212270948db1f30129728b625fc6109fc9ca0c604b714546909", size = 13117921, upload-time = "2026-07-06T13:13:03.476Z" }, + { url = "https://files.pythonhosted.org/packages/5e/09/86ac3968d2a18e28fce3ba1186fc21728e9f299b96a806d6f204f1e0aaed/policyengine_us-2.2.1-py3-none-any.whl", hash = "sha256:0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6", size = 14885537, upload-time = "2026-09-15T04:45:46.649Z" }, ] [[package]] @@ -3840,7 +3839,7 @@ wheels = [ [[package]] name = "spm-calculator" -version = "0.3.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "census" }, @@ -3850,9 +3849,9 @@ dependencies = [ { name = "requests" }, { name = "us" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/3b/b805c7e3e18c5b5c00f61b60112f9690d084c910e2481bc020f35390d8fd/spm_calculator-0.3.1.tar.gz", hash = "sha256:41f2f4d00d8c03422a7d57b800052e7760b88e463a5884802f83ed58d35c18c1", size = 75945, upload-time = "2026-04-17T19:52:39.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/5e/3f28b212c401795990250bb3daec741f699a8c7dc2a23a3db7005df6feb6/spm_calculator-1.0.0.tar.gz", hash = "sha256:a99aac8c2c0bf81a9455105bbf872366f1ea9066a99cd793714881cd3db846dc", size = 7150825, upload-time = "2026-09-11T15:45:12.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/1b/29f705f8a96fc7f55f2c07dfcddbbae78efdc6f174d25d4a0560fc3f5cf9/spm_calculator-0.3.1-py3-none-any.whl", hash = "sha256:52c57ecc5a240ec941b0f2b0d93bc4fa437ef6250e233baed8e11916fa9c1150", size = 57826, upload-time = "2026-04-17T19:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/19/a0/c484f69a0ebf88a9b9fd0ac28176f8df66550f46714f1b0c5ec0b360824e/spm_calculator-1.0.0-py3-none-any.whl", hash = "sha256:e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305", size = 7395384, upload-time = "2026-09-11T15:45:10.492Z" }, ] [[package]] From 60d0ddd9c4a21908b7acd97d97613be8b7f92790 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:07:05 +0400 Subject: [PATCH 3/7] ci: verify simulation worker compatibility --- .../check-policyengine-bundle-supported.sh | 12 +- .github/request-simulation-model-versions.sh | 13 +- .github/workflows/pr.yml | 2 + .github/workflows/push.yml | 4 + policyengine_api/worker_spm_release.py | 122 +++++++++++++++++ tests/unit/test_cloud_run_deploy_scripts.py | 1 + tests/unit/test_worker_spm_release.py | 124 ++++++++++++++++++ 7 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 policyengine_api/worker_spm_release.py create mode 100644 tests/unit/test_worker_spm_release.py diff --git a/.github/check-policyengine-bundle-supported.sh b/.github/check-policyengine-bundle-supported.sh index f48d1f076..20e78734d 100755 --- a/.github/check-policyengine-bundle-supported.sh +++ b/.github/check-policyengine-bundle-supported.sh @@ -51,10 +51,16 @@ if [ "$CHECK_ONLY_IF_CHANGED" = "1" ]; then || true )" - if [ "$current_version" = "$base_version" ]; then - echo "PolicyEngine .py bundle pin is unchanged; skipping simulation API support check." + if [ "$current_version" = "$base_version" ] \ + && git diff --quiet "origin/${BASE_REF}" -- \ + policyengine_api/spm.py policyengine_api/worker_spm.py \ + policyengine_api/worker_spm_release.py policyengine_api/constants.py \ + policyengine_api/country.py \ + .github/check-policyengine-bundle-supported.sh \ + .github/request-simulation-model-versions.sh; then + echo "Bundle pin and SPM integration are unchanged; skipping simulation API support check." exit 0 fi fi -bash "$VERSION_GUARD_SCRIPT" -py "$current_version" +bash "$VERSION_GUARD_SCRIPT" -py "$current_version" --check-installed-spm diff --git a/.github/request-simulation-model-versions.sh b/.github/request-simulation-model-versions.sh index 27a68062c..b2b38240d 100755 --- a/.github/request-simulation-model-versions.sh +++ b/.github/request-simulation-model-versions.sh @@ -23,12 +23,14 @@ usage() { echo "Optional compatibility checks:" echo " -us us_version Expected bundled policyengine-us version" echo " -uk uk_version Expected bundled policyengine-uk version" + echo " --check-installed-spm Compare the installed bundle with worker capability" exit 1 } POLICYENGINE_VERSION="" US_VERSION="" UK_VERSION="" +CHECK_INSTALLED_SPM=0 while [ $# -gt 0 ]; do case "$1" in @@ -44,6 +46,10 @@ while [ $# -gt 0 ]; do UK_VERSION="$2" shift 2 ;; + --check-installed-spm) + CHECK_INSTALLED_SPM=1 + shift + ;; -h|--help) usage ;; @@ -70,7 +76,7 @@ if [ -n "$UK_VERSION" ]; then fi echo "" -VERSIONS_RESPONSE=$(curl -s "${GATEWAY_URL}/versions") +VERSIONS_RESPONSE=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 60 "${GATEWAY_URL}/versions") if [ -z "$VERSIONS_RESPONSE" ]; then echo "ERROR: Failed to fetch versions from gateway" @@ -114,6 +120,11 @@ check_country_route() { check_country_route "us" "$US_VERSION" check_country_route "uk" "$UK_VERSION" +if [ "$CHECK_INSTALLED_SPM" = "1" ]; then + printf '%s' "$VERSIONS_RESPONSE" \ + | uv run --frozen python -m policyengine_api.worker_spm_release "$POLICYENGINE_VERSION" +fi + echo "" echo "SUCCESS: PolicyEngine bundle route is deployed and ready" exit 0 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dee90cc71..7128ce6e3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,6 +27,8 @@ jobs: fetch-depth: 0 - name: Install jq run: sudo apt-get install -y jq + - name: Set up uv for installed bundle validation + uses: astral-sh/setup-uv@v6 - name: Check simulation API supports updated PolicyEngine bundle run: bash .github/check-policyengine-bundle-supported.sh --if-changed-from-base "${{ github.base_ref }}" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 1390bb8cb..7ae64b7a3 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -60,6 +60,8 @@ jobs: uses: actions/checkout@v4 - name: Install jq run: sudo apt-get install -y jq + - name: Set up uv for installed bundle validation + uses: astral-sh/setup-uv@v6 - name: Check simulation API supports PolicyEngine bundle run: bash .github/check-policyengine-bundle-supported.sh @@ -391,6 +393,8 @@ jobs: uses: actions/checkout@v4 - name: Install jq run: sudo apt-get install -y jq + - name: Set up uv for installed bundle validation + uses: astral-sh/setup-uv@v6 - name: Check simulation API supports PolicyEngine bundle run: bash .github/check-policyengine-bundle-supported.sh diff --git a/policyengine_api/worker_spm_release.py b/policyengine_api/worker_spm_release.py new file mode 100644 index 000000000..8f876e0d2 --- /dev/null +++ b/policyengine_api/worker_spm_release.py @@ -0,0 +1,122 @@ +"""Validate the installed API bundle against a simulation worker registry.""" + +from __future__ import annotations + +from importlib.metadata import version as distribution_version +import json +import sys + +from policyengine_api import constants +from policyengine_api.worker_spm import validate_worker_spm + + +REQUIRED_BUNDLE_PACKAGES = ( + "policyengine", + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator", +) + + +class RegistryCapability: + """Expose one registry document through the request validator interface.""" + + def __init__(self, registry: dict): + self.registry = registry + + def get_spm_capability( + self, + country, + model_version, + *, + policyengine_version, + ): + capabilities = self.registry.get("spm_capabilities") + if not isinstance(capabilities, dict): + return None + return capabilities.get(policyengine_version) + + +def _bundle_package_versions() -> dict[str, str]: + bundle = constants._policyengine_bundle + packages = bundle.get("packages") if isinstance(bundle, dict) else None + if not isinstance(packages, dict): + raise ValueError("The installed PolicyEngine bundle manifest has no packages") + + versions: dict[str, str] = {} + for package_name in REQUIRED_BUNDLE_PACKAGES: + package = packages.get(package_name) + package_version = package.get("version") if isinstance(package, dict) else None + if not isinstance(package_version, str) or not package_version: + raise ValueError(f"The installed bundle does not identify {package_name}") + versions[package_name] = package_version + return versions + + +def _registered_application( + registry: dict, + route_kind: str, + route_version: str, +) -> str: + routes = registry.get(route_kind) if isinstance(registry, dict) else None + app = routes.get(route_version) if isinstance(routes, dict) else None + if not isinstance(app, str) or not app: + raise ValueError( + f"The worker registry has no {route_kind} {route_version} application" + ) + return app + + +def validate_installed_worker(registry: dict, expected_bundle: str) -> dict | None: + """Validate local package versions, worker routes, and SPM capability.""" + versions = _bundle_package_versions() + if versions["policyengine"] != expected_bundle: + raise ValueError("The bundle manifest differs from the release requirement") + if constants.POLICYENGINE_VERSION != expected_bundle: + raise ValueError("The API runtime differs from the release requirement") + + for package_name, expected_version in versions.items(): + if distribution_version(package_name) != expected_version: + raise ValueError( + f"Installed {package_name} does not match the bundle manifest" + ) + + bundle_app = _registered_application(registry, "policyengine", expected_bundle) + for country_id, package_name in ( + ("us", "policyengine-us"), + ("uk", "policyengine-uk"), + ): + country_app = _registered_application( + registry, + country_id, + versions[package_name], + ) + if country_app != bundle_app: + raise ValueError( + f"The {country_id} route resolves to a different worker application" + ) + + return validate_worker_spm( + "us", + gateway=RegistryCapability(registry), + policyengine_version=expected_bundle, + model_version=versions["policyengine-us"], + ) + + +def main() -> None: + """Read a registry document from stdin and validate one bundle version.""" + if len(sys.argv) != 2: + raise SystemExit( + "Usage: python -m policyengine_api.worker_spm_release BUNDLE_VERSION" + ) + selection = validate_installed_worker(json.load(sys.stdin), sys.argv[1]) + if selection is None: + print("The installed legacy bundle and registered worker agree") + else: + print("The installed bundle and registered worker capability agree") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 77cb6fc1d..aabce5fc3 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -482,6 +482,7 @@ def test_policyengine_bundle_support_check_passes_pyproject_pin_to_guard(tmp_pat assert capture_path.read_text(encoding="utf-8").splitlines() == [ "-py", current_version, + "--check-installed-spm", ] diff --git a/tests/unit/test_worker_spm_release.py b/tests/unit/test_worker_spm_release.py new file mode 100644 index 000000000..416af4113 --- /dev/null +++ b/tests/unit/test_worker_spm_release.py @@ -0,0 +1,124 @@ +"""Release checks compare installed packages with the selected worker.""" + +from copy import deepcopy + +import pytest + +from policyengine_api import constants, worker_spm, worker_spm_release +from policyengine_api.spm import SPMValidationError + + +BUNDLE_VERSION = "6.1.2" +PACKAGE_VERSIONS = { + "policyengine": BUNDLE_VERSION, + "policyengine-core": "3.32.5", + "policyengine-us": "2.2.1", + "policyengine-uk": "2.90.2", + "spm-calculator": "1.0.0", +} +SELECTION = { + "forecast_content_sha256": "a" * 64, + "scenario": "fixture", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +WORKER_APP = "fixture-worker" + + +@pytest.fixture +def registry(monkeypatch): + monkeypatch.setattr(constants, "POLICYENGINE_VERSION", BUNDLE_VERSION) + monkeypatch.setattr( + constants, + "_policyengine_bundle", + { + "packages": { + name: {"name": name, "version": package_version} + for name, package_version in PACKAGE_VERSIONS.items() + } + }, + ) + monkeypatch.setattr( + worker_spm_release, + "distribution_version", + PACKAGE_VERSIONS.__getitem__, + ) + monkeypatch.setattr(worker_spm, "normalize_spm_selection", lambda *_: SELECTION) + return { + "policyengine": {BUNDLE_VERSION: WORKER_APP}, + "us": {PACKAGE_VERSIONS["policyengine-us"]: WORKER_APP}, + "uk": {PACKAGE_VERSIONS["policyengine-uk"]: WORKER_APP}, + "spm_capabilities": { + BUNDLE_VERSION: { + "contract_version": "canonical-spm-v1", + "defaults": dict(SELECTION), + } + }, + } + + +def test_release_accepts_matching_installed_bundle_and_worker(registry): + assert ( + worker_spm_release.validate_installed_worker(registry, BUNDLE_VERSION) + == SELECTION + ) + + +@pytest.mark.parametrize( + "mutation", + ["missing-capability", "wrong-contract", "wrong-forecast"], +) +def test_release_rejects_worker_without_matching_spm_capability(registry, mutation): + registry = deepcopy(registry) + capability = registry["spm_capabilities"][BUNDLE_VERSION] + if mutation == "missing-capability": + registry["spm_capabilities"] = {} + elif mutation == "wrong-contract": + capability["contract_version"] = "different-contract" + else: + capability["defaults"]["forecast_content_sha256"] = "b" * 64 + + with pytest.raises(SPMValidationError): + worker_spm_release.validate_installed_worker(registry, BUNDLE_VERSION) + + +@pytest.mark.parametrize("route", ["policyengine", "us", "uk"]) +def test_release_rejects_missing_worker_route(registry, route): + registry[route] = {} + with pytest.raises(ValueError, match="no .* application"): + worker_spm_release.validate_installed_worker(registry, BUNDLE_VERSION) + + +@pytest.mark.parametrize("route", ["us", "uk"]) +def test_release_rejects_country_route_to_different_worker(registry, route): + version = next(iter(registry[route])) + registry[route][version] = "different-worker" + with pytest.raises(ValueError, match="different worker"): + worker_spm_release.validate_installed_worker(registry, BUNDLE_VERSION) + + +def test_release_rejects_installed_distribution_outside_manifest( + registry, + monkeypatch, +): + installed = {**PACKAGE_VERSIONS, "policyengine-core": "0.0.1"} + monkeypatch.setattr( + worker_spm_release, + "distribution_version", + installed.__getitem__, + ) + with pytest.raises(ValueError, match="policyengine-core"): + worker_spm_release.validate_installed_worker(registry, BUNDLE_VERSION) + + +def test_release_rejects_requirement_outside_manifest(registry): + with pytest.raises(ValueError, match="manifest"): + worker_spm_release.validate_installed_worker(registry, "7.0.0") + + +def test_release_rejects_manifest_without_complete_package_set(registry): + constants._policyengine_bundle["packages"].pop("spm-calculator") + with pytest.raises(ValueError, match="spm-calculator"): + worker_spm_release.validate_installed_worker(registry, BUNDLE_VERSION) From a1f0a848042b4701a459b5cf8f290fc8df907811 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:09:43 +0400 Subject: [PATCH 4/7] ci: update PolicyEngine bundle atomically --- .github/find-api-model-versions.py | 87 ++++++++++++--- .../scripts/update-policyengine-package.sh | 28 +++-- tests/unit/test_bundle_update_pins.py | 105 +++++++++++++++--- 3 files changed, 180 insertions(+), 40 deletions(-) diff --git a/.github/find-api-model-versions.py b/.github/find-api-model-versions.py index 7a9a9f09b..1e5a262a4 100644 --- a/.github/find-api-model-versions.py +++ b/.github/find-api-model-versions.py @@ -1,38 +1,89 @@ import argparse import os +import shlex import sys +from importlib.metadata import version as distribution_version +from policyengine.bundle import get_current_bundle from policyengine_api.constants import ( COUNTRY_PACKAGE_VERSIONS, POLICYENGINE_CORE_VERSION, POLICYENGINE_VERSION, ) +REQUIRED_PACKAGES = ( + "policyengine", + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator", +) + + +def _manifest_versions(bundle: dict) -> dict[str, str]: + packages = bundle.get("packages") + if not isinstance(packages, dict): + raise RuntimeError("PolicyEngine bundle manifest has no package mapping.") + + versions = {} + for package_name in REQUIRED_PACKAGES: + package = packages.get(package_name) + if not isinstance(package, dict) or not package.get("version"): + raise RuntimeError( + f"PolicyEngine bundle manifest has no version for {package_name}." + ) + versions[package_name] = str(package["version"]) + return versions + + +def _data_release_version(bundle: dict, country_id: str) -> str: + data_releases = bundle.get("data_releases") + release = data_releases.get(country_id) if isinstance(data_releases, dict) else None + if not isinstance(release, dict) or not release.get("version"): + raise RuntimeError( + f"PolicyEngine bundle manifest has no {country_id.upper()} data release." + ) + return str(release["version"]) + def find_api_model_versions() -> dict[str, str]: """ - Find the API model versions from the installed PolicyEngine bundle. + Find and validate the package and data versions in the installed bundle. """ - us_version = COUNTRY_PACKAGE_VERSIONS.get("us") - uk_version = COUNTRY_PACKAGE_VERSIONS.get("uk") + bundle = get_current_bundle() + if not isinstance(bundle, dict): + raise RuntimeError("Installed PolicyEngine bundle manifest is not an object.") - if not us_version: - print("Error: US package version not found.", file=sys.stderr) - sys.exit(1) + manifest_versions = _manifest_versions(bundle) + for package_name, manifest_version in manifest_versions.items(): + installed_version = distribution_version(package_name) + if installed_version != manifest_version: + raise RuntimeError( + f"Installed {package_name} version {installed_version} does not match " + f"bundle manifest version {manifest_version}." + ) - if not uk_version: - print("Error: UK package version not found.", file=sys.stderr) - sys.exit(1) - - if not POLICYENGINE_VERSION: - print("Error: PolicyEngine package version not found.", file=sys.stderr) - sys.exit(1) + expected_constants = { + "policyengine": POLICYENGINE_VERSION, + "policyengine-core": POLICYENGINE_CORE_VERSION, + "policyengine-us": COUNTRY_PACKAGE_VERSIONS.get("us"), + "policyengine-uk": COUNTRY_PACKAGE_VERSIONS.get("uk"), + } + for package_name, constant_version in expected_constants.items(): + if constant_version != manifest_versions[package_name]: + raise RuntimeError( + f"API version for {package_name} is {constant_version}, but the bundle " + f"manifest specifies {manifest_versions[package_name]}." + ) return { - "POLICYENGINE_VERSION": POLICYENGINE_VERSION, - "POLICYENGINE_CORE_VERSION": POLICYENGINE_CORE_VERSION, - "US_VERSION": us_version, - "UK_VERSION": uk_version, + "POLICYENGINE_VERSION": manifest_versions["policyengine"], + "POLICYENGINE_CORE_VERSION": manifest_versions["policyengine-core"], + "US_VERSION": manifest_versions["policyengine-us"], + "UK_VERSION": manifest_versions["policyengine-uk"], + "SPM_CALCULATOR_VERSION": manifest_versions["spm-calculator"], + "US_DATA_VERSION": _data_release_version(bundle, "us"), + "UK_DATA_VERSION": _data_release_version(bundle, "uk"), } @@ -57,7 +108,7 @@ def find_api_model_versions_and_output_to_github(): if args.shell: for key, value in find_api_model_versions().items(): - print(f"{key}={value}") + print(f"{key}={shlex.quote(value)}") else: find_api_model_versions_and_output_to_github() print("API model versions found and written to GitHub environment.") diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index ac8345fec..12a47403e 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -11,6 +11,7 @@ # repository_dispatch trigger passes the just-released version here); # otherwise the latest version on PyPI is used. # FORCE=1 allows targeting a version that is not newer than the current pin. +# LOCK_RETRY_SECONDS controls the pause between lock retries (default: 30). set -euo pipefail DRY_RUN=0 @@ -30,6 +31,12 @@ Update PolicyEngine .py bundle from ${CURRENT} to ${LATEST}. - policyengine-core: ${POLICYENGINE_CORE_VERSION:-resolved during update} - policyengine-us: ${US_VERSION:-resolved during update} - policyengine-uk: ${UK_VERSION:-resolved during update} +- spm-calculator: ${SPM_CALCULATOR_VERSION:-resolved during update} + +## Certified data releases + +- US: ${US_DATA_VERSION:-resolved during update} +- UK: ${UK_DATA_VERSION:-resolved during update} --- Generated automatically by GitHub Actions @@ -84,11 +91,9 @@ if [[ "$DRY_RUN" == "1" ]]; then exit 0 fi -EXISTING_PR=$(gh pr list \ - --head "$BRANCH" \ - --state open \ - --json number \ - --jq '.[0].number' 2>/dev/null || true) +EXISTING_PR=$(gh pr view "$BRANCH" \ + --json number,state \ + --jq 'select(.state == "OPEN") | .number' 2>/dev/null || true) if [[ -n "$EXISTING_PR" ]]; then echo "PR #${EXISTING_PR} already exists for ${BRANCH}. Skipping." exit 0 @@ -138,13 +143,20 @@ for attempt in 1 2 3; do echo "ERROR: uv lock failed after ${attempt} attempts." >&2 exit 1 fi - echo "uv lock attempt ${attempt} failed; retrying in 30s..." - sleep 30 + echo "uv lock attempt ${attempt} failed; retrying in ${LOCK_RETRY_SECONDS:-30}s..." + sleep "${LOCK_RETRY_SECONDS:-30}" done -VERSIONS_OUTPUT=$(uv run python .github/find-api-model-versions.py --shell) +uv lock --check + +VERSIONS_OUTPUT=$(uv run --frozen python .github/find-api-model-versions.py --shell) eval "$VERSIONS_OUTPUT" +if [[ "$POLICYENGINE_VERSION" != "$LATEST" ]]; then + echo "Installed PolicyEngine version ${POLICYENGINE_VERSION} does not match requested version ${LATEST}." >&2 + exit 1 +fi + FRAGMENT="changelog.d/update-policyengine-bundle-${LATEST}.changed.md" echo "Update the PolicyEngine bundle to ${LATEST}." > "$FRAGMENT" diff --git a/tests/unit/test_bundle_update_pins.py b/tests/unit/test_bundle_update_pins.py index c13abb99b..75ec4a74f 100644 --- a/tests/unit/test_bundle_update_pins.py +++ b/tests/unit/test_bundle_update_pins.py @@ -9,6 +9,8 @@ REPO = Path(__file__).resolve().parents[2] SCRIPT = REPO / ".github/scripts/update-policyengine-package.sh" +SOURCE_VERSION = "4.0.0" +TARGET_VERSION = "4.1.0" @pytest.fixture @@ -16,12 +18,11 @@ def update_checkout(tmp_path): (tmp_path / "docker").mkdir() (tmp_path / "changelog.d").mkdir() (tmp_path / "pyproject.toml").write_text( - '[project]\ndependencies = ["policyengine[models]==5.2.0", ' - '"spm-calculator==0.3.1"]\n' + f'[project]\ndependencies = ["policyengine[models]=={SOURCE_VERSION}"]\n' ) (tmp_path / "docker/Dockerfile").write_text( - 'FROM python:3.12\nRUN pip install "policyengine[models]==5.2.0" ' - "spm-calculator==0.3.1 ipython\n" + "FROM python:3.12\n" + f'RUN pip install "policyengine[models]=={SOURCE_VERSION}" ipython\n' ) (tmp_path / "uv.lock").write_text("# registry resolution is stubbed\n") binaries = tmp_path / "bin" @@ -38,8 +39,23 @@ def update_checkout(tmp_path): stream.write(json.dumps([name, *args]) + "\\n") if name == "git" and args[:1] == ["ls-remote"]: sys.exit(2) +if name == "gh" and args[:2] == ["pr", "view"]: + if os.environ.get("BUNDLE_TEST_OPEN_PR"): + print(os.environ["BUNDLE_TEST_OPEN_PR"]) + sys.exit(0) + sys.exit(1) +if name == "uv" and args[:2] == ["lock", "--upgrade-package"]: + if os.environ.get("BUNDLE_TEST_LOCK_FAIL"): + sys.exit(1) if name == "uv" and args[:1] == ["run"]: - print("POLICYENGINE_VERSION=5.3.0") + reported = os.environ.get("BUNDLE_TEST_REPORTED_VERSION", "4.1.0") + print(f"POLICYENGINE_VERSION={reported}") + print("POLICYENGINE_CORE_VERSION=3.0.0") + print("US_VERSION=2.0.0") + print("UK_VERSION=2.1.0") + print("SPM_CALCULATOR_VERSION=1.0.0") + print("US_DATA_VERSION=us-data-release") + print("UK_DATA_VERSION=uk-data-release") """ for name in ("git", "gh", "uv"): path = binaries / name @@ -48,16 +64,18 @@ def update_checkout(tmp_path): return tmp_path -def run_update(root): +def run_update(root, *arguments, **environment): log = root / "calls.jsonl" result = subprocess.run( - ["bash", str(SCRIPT)], + ["bash", str(SCRIPT), *arguments], cwd=root, env={ **os.environ, "PATH": f"{root / 'bin'}{os.pathsep}{os.environ['PATH']}", - "LATEST_OVERRIDE": "5.3.0", + "LATEST_OVERRIDE": TARGET_VERSION, "BUNDLE_TEST_CALLS": str(log), + "LOCK_RETRY_SECONDS": "0", + **environment, }, capture_output=True, text=True, @@ -72,24 +90,33 @@ def test_automatic_update_changes_and_stages_both_image_pins(update_checkout): assert result.returncode == 0, result.stderr for name in ("pyproject.toml", "docker/Dockerfile"): text = (update_checkout / name).read_text() - assert "policyengine[models]==5.3.0" in text - assert "policyengine[models]==5.2.0" not in text - assert "spm-calculator==0.3.1" in text + assert f"policyengine[models]=={TARGET_VERSION}" in text + assert f"policyengine[models]=={SOURCE_VERSION}" not in text + assert "spm-calculator==" not in text assert [ "git", "add", "pyproject.toml", "docker/Dockerfile", "uv.lock", - "changelog.d/update-policyengine-bundle-5.3.0.changed.md", + f"changelog.d/update-policyengine-bundle-{TARGET_VERSION}.changed.md", ] in calls assert ["uv", "lock", "--upgrade-package", "policyengine"] in calls + assert ["uv", "lock", "--check"] in calls + assert [ + "uv", + "run", + "--frozen", + "python", + ".github/find-api-model-versions.py", + "--shell", + ] in calls -@pytest.mark.parametrize("pin", ["5.1.0", "5.2.01", "5.2.0rc1"]) +@pytest.mark.parametrize("pin", ["3.9.0", "4.0.01", "4.0.0rc1"]) def test_mismatched_image_pin_fails_before_file_changes(update_checkout, pin): image = update_checkout / "docker/Dockerfile" - image.write_text(image.read_text().replace("5.2.0", pin)) + image.write_text(image.read_text().replace(SOURCE_VERSION, pin)) before = { name: (update_checkout / name).read_bytes() for name in ("pyproject.toml", "docker/Dockerfile") @@ -101,3 +128,53 @@ def test_mismatched_image_pin_fails_before_file_changes(update_checkout, pin): assert (update_checkout / name).read_bytes() == data assert not any(call[:1] == ["uv"] for call in calls) assert not any(call[:2] == ["git", "commit"] for call in calls) + + +def test_dry_run_does_not_change_files(update_checkout): + before = { + name: (update_checkout / name).read_bytes() + for name in ("pyproject.toml", "docker/Dockerfile", "uv.lock") + } + result, calls = run_update(update_checkout, "--dry-run") + assert result.returncode == 0, result.stderr + assert "Dry run complete" in result.stdout + for name, data in before.items(): + assert (update_checkout / name).read_bytes() == data + assert not any(call[:1] == ["uv"] for call in calls) + + +def test_lock_failure_stops_before_commit(update_checkout): + result, calls = run_update(update_checkout, BUNDLE_TEST_LOCK_FAIL="1") + assert result.returncode != 0 + assert "uv lock failed after 3 attempts" in result.stderr + assert sum( + call == ["uv", "lock", "--upgrade-package", "policyengine"] + for call in calls + ) == 3 + assert not any(call[:2] == ["git", "commit"] for call in calls) + + +def test_manifest_version_mismatch_stops_before_commit(update_checkout): + result, calls = run_update( + update_checkout, BUNDLE_TEST_REPORTED_VERSION="4.0.9" + ) + assert result.returncode != 0 + assert "does not match requested version" in result.stderr + assert not any(call[:2] == ["git", "commit"] for call in calls) + + +def test_existing_pull_request_is_found_by_exact_branch(update_checkout): + result, calls = run_update(update_checkout, BUNDLE_TEST_OPEN_PR="321") + assert result.returncode == 0, result.stderr + assert "PR #321 already exists" in result.stdout + assert [ + "gh", + "pr", + "view", + f"auto/update-policyengine-bundle-{TARGET_VERSION}", + "--json", + "number,state", + "--jq", + 'select(.state == "OPEN") | .number', + ] in calls + assert not any(call[:1] == ["uv"] for call in calls) From 729d6d7759bac678bc7f6a641aa1cd552f905891 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:27:13 +0400 Subject: [PATCH 5/7] test: update fixtures for PolicyEngine 6 --- .../test_simulation_gateway_contract.py | 9 +++ tests/fixtures/libs/simulation_entrypoint.py | 8 ++ .../test_budget_window_in_flight_dedupe.py | 2 + .../integration/test_v2_catalog_installed.py | 8 +- tests/unit/libs/test_simulation_entrypoint.py | 14 +++- tests/unit/routes/test_canonical_spm.py | 4 +- ...st_household_and_user_policy_orm_routes.py | 17 ++++- tests/unit/services/test_economy_service.py | 74 +++++++++++-------- .../test_household_calculation_service.py | 7 +- tests/unit/services/test_worker_spm.py | 9 ++- tests/unit/test_country_spm.py | 22 ++++-- tests/unit/test_spm_settings.py | 1 + 12 files changed, 124 insertions(+), 51 deletions(-) diff --git a/tests/contract/test_simulation_gateway_contract.py b/tests/contract/test_simulation_gateway_contract.py index 12ccc0e8e..2452e3e38 100644 --- a/tests/contract/test_simulation_gateway_contract.py +++ b/tests/contract/test_simulation_gateway_contract.py @@ -15,6 +15,7 @@ MOCK_RESOLVED_APP_NAME, MOCK_SIMULATION_PAYLOAD_WITH_TELEMETRY, MOCK_SUBMIT_RESPONSE_SUCCESS, + MOCK_WORKER_VERSIONS, ) @@ -56,6 +57,10 @@ def test_gateway_comparison_submit_and_poll_contract(monkeypatch): monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://simulation.test") client = _client_for( { + ("GET", "/versions"): _response( + status_code=200, + json_data=MOCK_WORKER_VERSIONS, + ), ("POST", "/simulate/economy/comparison"): _response( status_code=202, json_data=MOCK_SUBMIT_RESPONSE_SUCCESS, @@ -87,6 +92,10 @@ def test_gateway_budget_window_submit_and_poll_contract(monkeypatch): monkeypatch.setenv("OLD_SIMULATION_GATEWAY_URL", "https://simulation.test") client = _client_for( { + ("GET", "/versions"): _response( + status_code=200, + json_data=MOCK_WORKER_VERSIONS, + ), ( "POST", "/simulate/economy/budget-window", diff --git a/tests/fixtures/libs/simulation_entrypoint.py b/tests/fixtures/libs/simulation_entrypoint.py index cdc5b488b..08ea50a8a 100644 --- a/tests/fixtures/libs/simulation_entrypoint.py +++ b/tests/fixtures/libs/simulation_entrypoint.py @@ -16,6 +16,7 @@ MODAL_EXECUTION_STATUS_FAILED, ) from tests.fixtures.spm import ( + worker_spm_capability, worker_result_fields, worker_versions_document, ) @@ -65,6 +66,9 @@ app_name=MOCK_RESOLVED_APP_NAME, country_version="1.459.0", ) +for bundle_version in ("3.4.0", "4.18.3"): + MOCK_WORKER_VERSIONS["policyengine"][bundle_version] = MOCK_RESOLVED_APP_NAME + MOCK_WORKER_VERSIONS["spm_capabilities"][bundle_version] = worker_spm_capability() MOCK_SUBMIT_RESPONSE_SUCCESS = { "job_id": MOCK_MODAL_JOB_ID, @@ -214,6 +218,10 @@ def mock_httpx_client(): ) as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client + mock_client.get.return_value = create_mock_httpx_response( + status_code=200, + json_data=MOCK_WORKER_VERSIONS, + ) yield mock_client diff --git a/tests/integration/test_budget_window_in_flight_dedupe.py b/tests/integration/test_budget_window_in_flight_dedupe.py index 4c3c45d8e..826154ca9 100644 --- a/tests/integration/test_budget_window_in_flight_dedupe.py +++ b/tests/integration/test_budget_window_in_flight_dedupe.py @@ -2,6 +2,7 @@ from flask import Flask from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from tests.fixtures.spm import worker_spm_capability class FakeRedis(InMemoryCacheBackend): @@ -31,6 +32,7 @@ def test_budget_window_in_flight_dedupe_uses_existing_batch_without_live_db( fake_cache = BudgetWindowCache(client=FakeRedis()) simulation_entrypoint = MagicMock() + simulation_entrypoint.get_spm_capability.return_value = worker_spm_capability() reform_impacts_service = MagicMock() simulation_entrypoint.run_budget_window_batch.return_value = ( diff --git a/tests/integration/test_v2_catalog_installed.py b/tests/integration/test_v2_catalog_installed.py index b09ce746a..f897bce6b 100644 --- a/tests/integration/test_v2_catalog_installed.py +++ b/tests/integration/test_v2_catalog_installed.py @@ -39,10 +39,10 @@ def test_installed_policyengine_catalog_is_complete_and_bounded() -> None: assert catalog.entity_counts() == { "models": 2, "model_versions": 2, - "variables": 6_649, - "parameter_nodes": 27_813, - "parameters": 99_006, - "parameter_values": 1_172_130, + "variables": 7_046, + "parameter_nodes": 29_118, + "parameters": 103_705, + "parameter_values": 1_192_826, "datasets": 2, "regions": 826, } diff --git a/tests/unit/libs/test_simulation_entrypoint.py b/tests/unit/libs/test_simulation_entrypoint.py index ddf51e16b..36fd6be9d 100644 --- a/tests/unit/libs/test_simulation_entrypoint.py +++ b/tests/unit/libs/test_simulation_entrypoint.py @@ -21,6 +21,7 @@ os.environ.setdefault("FLASK_DEBUG", "1") from policyengine_api.constants import ( # noqa: E402 + POLICYENGINE_VERSION, MODAL_EXECUTION_STATUS_COMPLETE, MODAL_EXECUTION_STATUS_FAILED, MODAL_EXECUTION_STATUS_RUNNING, @@ -57,8 +58,10 @@ MOCK_SIMULATION_PAYLOAD_WITH_TELEMETRY, MOCK_SIMULATION_RESULT, MOCK_SUBMIT_RESPONSE_SUCCESS, + MOCK_WORKER_VERSIONS, create_mock_httpx_response, ) +from tests.fixtures.spm import INSTALLED_SPM_SELECTION # noqa: E402 pytest_plugins = ("tests.fixtures.libs.simulation_entrypoint",) @@ -107,6 +110,9 @@ def _response(self, method, url, json=None): elif "/jobs/" in path: payload = MOCK_POLL_RESPONSE_RUNNING status_code = 202 + elif path == "/versions": + payload = MOCK_WORKER_VERSIONS + status_code = 200 elif "/versions/" in path: payload = { "latest": "1.459.0", @@ -475,6 +481,7 @@ def test__given_request_context__then_all_calls_forward_request_id( "/simulate/economy/budget-window", f"/jobs/{MOCK_MODAL_JOB_ID}", f"/budget-window-jobs/{MOCK_BATCH_JOB_ID}", + "/versions", "/versions/us", "/health", } @@ -544,7 +551,11 @@ def test__given_valid_payload__then_posts_to_correct_endpoint( # Then call_args = mock_httpx_client.post.call_args assert "/simulate/economy/comparison" in call_args[0][0] - assert call_args[1]["json"] == MOCK_SIMULATION_PAYLOAD + assert call_args[1]["json"] == { + **MOCK_SIMULATION_PAYLOAD, + "spm": INSTALLED_SPM_SELECTION, + "policyengine_version": POLICYENGINE_VERSION, + } def test__given_telemetry_payload__then_preserves_it_in_post_body( self, @@ -629,6 +640,7 @@ def test__given_api_v1_default_bundle_payload__then_posts_gateway_contract_body( "include_cliffs", "version", "policyengine_version", + "spm", "_metadata", "_telemetry", } diff --git a/tests/unit/routes/test_canonical_spm.py b/tests/unit/routes/test_canonical_spm.py index e3c55a015..f2282ccd7 100644 --- a/tests/unit/routes/test_canonical_spm.py +++ b/tests/unit/routes/test_canonical_spm.py @@ -294,7 +294,9 @@ def test_certification_checked_before_cached_response(certified, harness): @pytest.mark.parametrize("country_id", ["us", "uk"]) -def test_legacy_country_requests_do_not_receive_spm(harness, country_id): +def test_legacy_country_requests_do_not_receive_spm(harness, country_id, monkeypatch): + monkeypatch.setattr(spm, "_current_bundle", lambda: {}) + monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) client, country = harness response = client.post(f"/{country_id}/calculate", json={"household": HOUSEHOLD}) assert response.status_code == 200 diff --git a/tests/unit/routes/test_household_and_user_policy_orm_routes.py b/tests/unit/routes/test_household_and_user_policy_orm_routes.py index ee38bea40..43c782c52 100644 --- a/tests/unit/routes/test_household_and_user_policy_orm_routes.py +++ b/tests/unit/routes/test_household_and_user_policy_orm_routes.py @@ -25,8 +25,10 @@ update_user_policy, ) from policyengine_api.services.household_calculation_service import ( + CalculationResult, HouseholdCalculationService, ) +from tests.fixtures.spm import INSTALLED_SPM_SELECTION, household_result_fields def test_household_under_policy_returns_cached_json_object(orm_session_factory): @@ -65,8 +67,12 @@ def test_household_under_policy_returns_cached_json_object(orm_session_factory): policy_hash="policy-hash", country_package_version=COUNTRY_PACKAGE_VERSIONS["us"], policyengine_version=POLICYENGINE_VERSION, + spm=INSTALLED_SPM_SELECTION, + ), + CachedHouseholdCalculation( + household=stored_result, + **household_result_fields(years=["2026"]), ), - CachedHouseholdCalculation(household=stored_result), ) service = HouseholdCalculationService( primary_session_factory=orm_session_factory, @@ -108,7 +114,12 @@ def test_household_under_policy_calculates_and_caches_json_as_an_object( ) calculated = {"people": {"you": {"net_income": {"2026": 42}}}} country = SimpleNamespace( - calculate=Mock(return_value=calculated), + calculate=Mock( + return_value=CalculationResult( + household=calculated, + **household_result_fields(years=["2026"]), + ) + ), metadata={ "variables": {}, "entities": {"person": {"plural": "people", "roles": {}}}, @@ -134,6 +145,8 @@ def test_household_under_policy_calculates_and_caches_json_as_an_object( country.calculate.assert_called_once_with( {"people": {"you": {}}}, {"gov.example.parameter": 1}, + spm=INSTALLED_SPM_SELECTION, + spm_requested=False, ) diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index ab0fc7353..ab7b7581d 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -42,6 +42,11 @@ create_mock_budget_window_batch_execution, create_mock_reform_impact, ) +from tests.fixtures.spm import ( + resolved_options, + worker_result_fields, + worker_spm_capability, +) pytest_plugins = ("tests.fixtures.services.economy_service",) @@ -84,6 +89,28 @@ def make_http_status_error( ) +def complete_budget_window_result( + *, annual_values: dict[str, dict] | None = None, totals: dict | None = None +) -> dict: + years = ("2026", "2027", "2028") + annual_values = annual_values or {} + return { + "kind": "budgetWindow", + "startYear": years[0], + "endYear": years[-1], + "windowSize": len(years), + "annualImpacts": [ + { + "year": year, + **annual_values.get(year, {}), + **worker_result_fields(years=[year]), + } + for year in years + ], + "totals": totals or {}, + } + + class TestEconomyService: class TestGetEconomicImpact: @pytest.fixture @@ -372,7 +399,7 @@ def test__given_no_previous_impact__creates_new_simulation( write_values = ( mock_reform_impacts_service.set_reform_impact.call_args.kwargs ) - assert write_values["options"] == MOCK_OPTIONS + assert write_values["options"] == resolved_options(MOCK_OPTIONS) assert write_values["reform_impact_json"] == {} def test__given_existing_start_claim__does_not_submit_duplicate_simulation( @@ -518,6 +545,9 @@ def test__given_policies_created_through_orm__submits_decoded_json( "policyengine-simulation-test", MOCK_MODEL_VERSION, ) + simulation_gateway.get_spm_capability.return_value = ( + worker_spm_capability() + ) simulation_gateway.get_execution_id.return_value = "execution-1" simulation_gateway.run.return_value.run_id = "run-1" monkeypatch.setattr( @@ -998,22 +1028,18 @@ def test__given_completed_cached_result__returns_completed_batch_result( mock_simulation_entrypoint, mock_budget_window_cache, ): - completed_result = { - "kind": "budgetWindow", - "startYear": "2026", - "endYear": "2028", - "windowSize": 3, - "annualImpacts": [ - { + completed_result = complete_budget_window_result( + annual_values={ + "2026": { "year": "2026", "taxRevenueImpact": 100, "federalTaxRevenueImpact": 80, "stateTaxRevenueImpact": 20, "benefitSpendingImpact": -10, "budgetaryImpact": 90, - } - ], - "totals": { + }, + }, + totals={ "year": "Total", "taxRevenueImpact": 100, "federalTaxRevenueImpact": 80, @@ -1021,7 +1047,7 @@ def test__given_completed_cached_result__returns_completed_batch_result( "benefitSpendingImpact": -10, "budgetaryImpact": 90, }, - } + ) mock_budget_window_cache.get_completed_result.return_value = ( completed_result ) @@ -1074,14 +1100,7 @@ def test__given_completed_batch_poll__caches_result_and_returns_completed( mock_simulation_entrypoint, mock_budget_window_cache, ): - completed_result = { - "kind": "budgetWindow", - "startYear": "2026", - "endYear": "2028", - "windowSize": 3, - "annualImpacts": [], - "totals": {}, - } + completed_result = complete_budget_window_result() mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( create_mock_budget_window_batch_execution( @@ -1147,14 +1166,7 @@ def test__given_completed_batch_cache_write_fails__does_not_clear_batch_id( mock_simulation_entrypoint, mock_budget_window_cache, ): - completed_result = { - "kind": "budgetWindow", - "startYear": "2026", - "endYear": "2028", - "windowSize": 3, - "annualImpacts": [], - "totals": {}, - } + completed_result = complete_budget_window_result() mock_budget_window_cache.get_batch_job_id.return_value = "fc-budget-123" mock_budget_window_cache.set_completed_result.return_value = False mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( @@ -1445,9 +1457,9 @@ def test__given_reordered_options__uses_same_budget_window_cache_identity( mock_simulation_entrypoint, mock_budget_window_cache, ): - mock_budget_window_cache.get_completed_result.return_value = { - "kind": "budgetWindow" - } + mock_budget_window_cache.get_completed_result.return_value = ( + complete_budget_window_result() + ) economy_service.get_budget_window_economic_impact( **{ diff --git a/tests/unit/services/test_household_calculation_service.py b/tests/unit/services/test_household_calculation_service.py index f3176002d..06d5a5cf6 100644 --- a/tests/unit/services/test_household_calculation_service.py +++ b/tests/unit/services/test_household_calculation_service.py @@ -19,6 +19,7 @@ CalculationResult, HouseholdCalculationService, ) +from tests.fixtures.spm import INSTALLED_SPM_SELECTION, household_result_fields PACKAGE_ROOT = Path(__file__).parents[3] / "policyengine_api" @@ -88,6 +89,7 @@ def _identity() -> HouseholdCalculationIdentity: policy_hash="policy-hash", country_package_version=COUNTRY_PACKAGE_VERSIONS["us"], policyengine_version=POLICYENGINE_VERSION, + spm=INSTALLED_SPM_SELECTION, ) @@ -149,6 +151,7 @@ def calculate(self, household, policy, **_kwargs): return CalculationResult( household={"people": {"you": {"net_income": {"2026": 42}}}}, warnings=("net_income could not be calculated",), + **household_result_fields(years=["2026"]), ) service = HouseholdCalculationService( @@ -178,6 +181,7 @@ def test_calculation_uses_local_cache_without_recomputing(orm_session_factory): CachedHouseholdCalculation( household=calculated, warnings=("net_income could not be calculated",), + **household_result_fields(years=["2026"]), ), ) country = SimpleNamespace( @@ -208,8 +212,9 @@ def test_failed_cache_write_does_not_invalidate_successful_calculation( "variables": {}, "entities": {"person": {"plural": "people", "roles": {}}}, }, - calculate=lambda *_: SimpleNamespace( + calculate=lambda *_, **__: SimpleNamespace( household={"people": {"you": {}}}, + **household_result_fields(years=["2026"]), ), ) diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py index 1bbcc17c7..b57e3c33e 100644 --- a/tests/unit/services/test_worker_spm.py +++ b/tests/unit/services/test_worker_spm.py @@ -43,9 +43,12 @@ def test_actual_settings_validator_rejects_explicit_settings_on_legacy_worker( "packages": {"policyengine-us": {"version": "1.764.6"}}, }, ): - assert validate_worker_spm(country_id) is None - with pytest.raises(SPMValidationError) as error: - validate_worker_spm(country_id, {"geography_kind": "national"}) + with patch( + "policyengine_api.spm.simulation_supports_spm", return_value=False + ): + assert validate_worker_spm(country_id) is None + with pytest.raises(SPMValidationError) as error: + validate_worker_spm(country_id, {"geography_kind": "national"}) assert error.value.code == "SPM_SETTINGS_UNSUPPORTED" diff --git a/tests/unit/test_country_spm.py b/tests/unit/test_country_spm.py index a36d9f8a0..c9355ced3 100644 --- a/tests/unit/test_country_spm.py +++ b/tests/unit/test_country_spm.py @@ -271,8 +271,6 @@ def test_real_country_state_only_tax_succeeds_and_receipt_stays_empty( "variable", [ "spm_unit_spm_threshold", - "spm_unit_capped_housing_subsidy", - "spm_unit_net_income", "spm_unit_is_in_spm_poverty", ], ) @@ -291,8 +289,6 @@ def test_real_country_state_only_spm_dependency_requires_geography( "variable", [ "spm_unit_spm_threshold", - "spm_unit_capped_housing_subsidy", - "spm_unit_net_income", "spm_unit_is_in_spm_poverty", ], ) @@ -310,13 +306,18 @@ def test_real_country_state_only_dependency_is_null_when_nothing_was_chosen( assert result.spm_config["geography_kind"] == "county" -@pytest.mark.parametrize("county", ["99999", "malformed"]) -def test_real_country_unknown_county_is_structured(real_canonical_country, county): +@pytest.mark.parametrize( + ("county", "expected_code"), + [("99999", "SPM_GEOGRAPHY_UNAVAILABLE"), ("malformed", "SPM_GEOGRAPHY_REQUIRED")], +) +def test_real_country_unknown_county_is_structured( + real_canonical_country, county, expected_code +): household = requested_household() household["households"]["household"]["county_fips"] = {"2024": county} with pytest.raises(ValueError) as caught: real_canonical_country.calculate(household, None, spm_requested=True) - assert spm.spm_error_detail(caught.value)["code"] == "SPM_GEOGRAPHY_UNAVAILABLE" + assert spm.spm_error_detail(caught.value)["code"] == expected_code def test_real_country_unknown_area_is_structured(real_canonical_country): @@ -601,10 +602,15 @@ def test_real_http_tax_only_outside_artifact_years_is_lazy( def test_real_http_unsupported_spm_year_is_structured_only_when_calculated( real_http_client, geography, variable, axes ): + household = household_in_year(variable, 2036, axes=axes) + if variable == "spm_unit_net_income": + household["spm_units"]["spm_unit"]["housing_assistance"] = { + "2036": 1_000 + } response = real_http_client.post( "/us/calculate", json={ - "household": household_in_year(variable, 2036, axes=axes), + "household": household, "spm": selection_for_geography(geography), }, ) diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index 615fba9d2..fc61f9dca 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -556,6 +556,7 @@ def test_a_model_this_build_cannot_load_reads_as_no_canonical_model( def refuse(name): raise failure + monkeypatch.setattr(spm, "_current_bundle", lambda: deepcopy(LEGACY_BUNDLE)) monkeypatch.setattr(spm.importlib, "import_module", refuse) assert spm._installed_country_implements_spm("us") is False From 7933182498c1abdd49d48dd21e46d10a7202915f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:28:53 +0400 Subject: [PATCH 6/7] docs: explain PolicyEngine 6 compatibility --- changelog.d/policyengine-6-1-2.changed.md | 1 + docs/canonical-spm.md | 57 +++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 changelog.d/policyengine-6-1-2.changed.md diff --git a/changelog.d/policyengine-6-1-2.changed.md b/changelog.d/policyengine-6-1-2.changed.md new file mode 100644 index 000000000..5e9670f94 --- /dev/null +++ b/changelog.d/policyengine-6-1-2.changed.md @@ -0,0 +1 @@ +Update the API to PolicyEngine.py 6.1.2, including PolicyEngine Core 3.32.5, PolicyEngine US 2.2.1, PolicyEngine UK 2.90.2, and spm-calculator 1.0.0. Validate that simulation workers report the same bundle and SPM measurement settings before submitting US economy calculations. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md index 042e3be71..fe0bb46e2 100644 --- a/docs/canonical-spm.md +++ b/docs/canonical-spm.md @@ -1,8 +1,8 @@ # Canonical SPM household API contract -This contract is enabled only by an installed, certified US bundle that pins its -SPM forecast hash/scenario and supports the country `spm` constructor. This change -does not select a new released model or promote a deployment. +This contract is enabled only by an installed, certified US bundle that identifies +one SPM forecast file and scenario and supports the country `spm` constructor. +Installing a package version does not deploy the API or its simulation worker. Whether a bundle predates this contract is decided by the installed country model's capability, never by a bundle version string; the automated bundle update @@ -13,10 +13,50 @@ its existing behavior when settings are omitted and rejects explicit SPM setting with `SPM_SETTINGS_UNSUPPORTED`, whatever its version. A bundle whose US model does implement the constructor but ships no certified `measurements.spm` configuration fails closed with `SPM_CONFIGURATION_UNAVAILABLE`: such a -deployment also fails `/readiness-check`, so the condition is reported where the -release is gated rather than only on each request. Other countries retain their +deployment also fails `/readiness-check`, so the release checks report the +condition rather than leaving it to individual requests. Other countries retain their behavior and reject US-only SPM settings. +## PolicyEngine 6.1.2 compatibility + +This API installs `policyengine[models]==6.1.2`. That requirement installs the +five package versions recorded in the PolicyEngine.py bundle manifest: + +| Package | Version | +| --- | --- | +| `policyengine` | 6.1.2 | +| `policyengine-core` | 3.32.5 | +| `policyengine-us` | 2.2.1 | +| `policyengine-uk` | 2.90.2 | +| `spm-calculator` | 1.0.0 | + +The same manifest identifies `populace-us-2024-spm-20260915` as the US data +release and `policyengine-uk-data-1.56.16` as the UK data release. The API does +not declare separate country-model or SPM calculator requirements. This prevents +an independently selected package version from disagreeing with the tested +combination in the manifest. + +PolicyEngine.py 6 also records the exact SPM forecast file and default scenario +used for US calculations. The API resolves those values before it submits an +economy calculation. It then checks that the worker reports the same values and +that every completed result includes a receipt describing the forecast, scenario, +years, and geography it used. This is the concrete behavior referred to elsewhere +as SPM selection and provenance. + +The API and simulation worker can be reviewed independently. Economy calculations +using this API version require a worker whose `/versions` response maps the +PolicyEngine.py, US, and UK versions above to the same worker application and +reports the matching `canonical-spm-v1` settings. Until such a worker is deployed, +the API returns `SPM_CONFIGURATION_UNAVAILABLE` instead of submitting a calculation +to an incompatible worker. [Simulation API PR 703](https://github.com/PolicyEngine/policyengine-sim-api/pull/703) +implements the corresponding worker package update. + +The automated dependency updater changes the single `policyengine[models]` +requirement in `pyproject.toml` and the generic Docker image, refreshes `uv.lock`, +and checks all five installed distributions against the new manifest. Its pull +request description lists the five package versions and both certified data +release identifiers. + ## Selecting a measurement `GET /us/metadata` exposes `result.spm.available`. When true, `settings_schema` @@ -219,7 +259,8 @@ SPM input failures return HTTP 400 in the existing validation envelope: ``` `SPM_GEOGRAPHY_REQUIRED` indicates missing explicit geography for an SPM -dependency; `SPM_GEOGRAPHY_UNAVAILABLE` indicates malformed/unknown county or area; +dependency, including a county value that is not a five-digit FIPS code; +`SPM_GEOGRAPHY_UNAVAILABLE` indicates a syntactically valid but unknown county or area; `SPM_COMPOSITION_REQUIRED` indicates no classified SPM adult. Country error text is retained. `SPM_YEAR_UNAVAILABLE` indicates an unsupported measurement year. Settings errors use `SPM_SETTINGS_INVALID`, @@ -271,8 +312,8 @@ their code and message for the existing cache lifetime. Later reads replay that failure after API service restarts without polling or resubmitting the failed job. Canonical cache identity and runtime-bundle refresh rules still apply. -See the [canonical SPM worker PR](https://github.com/PolicyEngine/policyengine-sim-api/pull/677) -for the implemented worker paths and remaining coordinated release gates. +See [Simulation API PR 703](https://github.com/PolicyEngine/policyengine-sim-api/pull/703) +for the corresponding worker package update and compatibility checks. Partial selections preserve omitted fields in JSON; omissions inherit the certified From 736c26c3170ca182a34b73e4889f578c49036246 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:58:47 +0400 Subject: [PATCH 7/7] ci: run API tests before simulation rollout --- .github/workflows/pr.yml | 7 +------ tests/unit/services/test_economy_service.py | 4 +--- tests/unit/services/test_worker_spm.py | 4 +--- tests/unit/test_bundle_update_pins.py | 15 ++++++++------- tests/unit/test_country_spm.py | 4 +--- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7128ce6e3..bf482160a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -94,7 +94,6 @@ jobs: test_container_builds: name: Docker runs-on: ubuntu-latest - needs: ensure-policyengine-bundle-supported-by-simulation-api permissions: contents: read packages: write @@ -112,7 +111,6 @@ jobs: test_cloud_run_container_builds: name: Cloud Run container runs-on: ubuntu-latest - needs: ensure-policyengine-bundle-supported-by-simulation-api permissions: contents: read steps: @@ -123,7 +121,6 @@ jobs: test_env_vars: name: Test environment variables runs-on: ubuntu-latest - needs: ensure-policyengine-bundle-supported-by-simulation-api permissions: contents: read id-token: write @@ -152,9 +149,7 @@ jobs: test: name: Test runs-on: ubuntu-latest - needs: - - ensure-policyengine-bundle-supported-by-simulation-api - - test_env_vars + needs: test_env_vars permissions: contents: read id-token: write diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index ab7b7581d..515e4da18 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -545,9 +545,7 @@ def test__given_policies_created_through_orm__submits_decoded_json( "policyengine-simulation-test", MOCK_MODEL_VERSION, ) - simulation_gateway.get_spm_capability.return_value = ( - worker_spm_capability() - ) + simulation_gateway.get_spm_capability.return_value = worker_spm_capability() simulation_gateway.get_execution_id.return_value = "execution-1" simulation_gateway.run.return_value.run_id = "run-1" monkeypatch.setattr( diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py index b57e3c33e..09152c0a5 100644 --- a/tests/unit/services/test_worker_spm.py +++ b/tests/unit/services/test_worker_spm.py @@ -43,9 +43,7 @@ def test_actual_settings_validator_rejects_explicit_settings_on_legacy_worker( "packages": {"policyengine-us": {"version": "1.764.6"}}, }, ): - with patch( - "policyengine_api.spm.simulation_supports_spm", return_value=False - ): + with patch("policyengine_api.spm.simulation_supports_spm", return_value=False): assert validate_worker_spm(country_id) is None with pytest.raises(SPMValidationError) as error: validate_worker_spm(country_id, {"geography_kind": "national"}) diff --git a/tests/unit/test_bundle_update_pins.py b/tests/unit/test_bundle_update_pins.py index 75ec4a74f..2ccfb19e6 100644 --- a/tests/unit/test_bundle_update_pins.py +++ b/tests/unit/test_bundle_update_pins.py @@ -147,17 +147,18 @@ def test_lock_failure_stops_before_commit(update_checkout): result, calls = run_update(update_checkout, BUNDLE_TEST_LOCK_FAIL="1") assert result.returncode != 0 assert "uv lock failed after 3 attempts" in result.stderr - assert sum( - call == ["uv", "lock", "--upgrade-package", "policyengine"] - for call in calls - ) == 3 + assert ( + sum( + call == ["uv", "lock", "--upgrade-package", "policyengine"] + for call in calls + ) + == 3 + ) assert not any(call[:2] == ["git", "commit"] for call in calls) def test_manifest_version_mismatch_stops_before_commit(update_checkout): - result, calls = run_update( - update_checkout, BUNDLE_TEST_REPORTED_VERSION="4.0.9" - ) + result, calls = run_update(update_checkout, BUNDLE_TEST_REPORTED_VERSION="4.0.9") assert result.returncode != 0 assert "does not match requested version" in result.stderr assert not any(call[:2] == ["git", "commit"] for call in calls) diff --git a/tests/unit/test_country_spm.py b/tests/unit/test_country_spm.py index c9355ced3..6913ea698 100644 --- a/tests/unit/test_country_spm.py +++ b/tests/unit/test_country_spm.py @@ -604,9 +604,7 @@ def test_real_http_unsupported_spm_year_is_structured_only_when_calculated( ): household = household_in_year(variable, 2036, axes=axes) if variable == "spm_unit_net_income": - household["spm_units"]["spm_unit"]["housing_assistance"] = { - "2036": 1_000 - } + household["spm_units"]["spm_unit"]["housing_assistance"] = {"2036": 1_000} response = real_http_client.post( "/us/calculate", json={