Skip to content

Move the API bundle to the canonical PolicyEngine 6.0.0 tuple - #3837

Draft
MaxGhenis wants to merge 3 commits into
max/spm-api-cache-capability-20260912from
max/canonical-bundle-6-0-0-20260915
Draft

MaxGhenis wants to merge 3 commits into
max/spm-api-cache-capability-20260912from
max/canonical-bundle-6-0-0-20260915

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Moves the API's PolicyEngine bundle from 5.2.0 to the canonical 6.0.0 tuple and raises the separate spm-calculator pin from 0.3.1 to 1.0.0.

policyengine 6.0.0 was published to PyPI at 2026-09-15T13:37:27Z. Its models extra binds policyengine-core==3.32.5, policyengine-us==2.2.1, policyengine-uk==2.90.2 and spm-calculator==1.0.0, and its bundle manifest certifies the SPM measurement (scenario ce_trend, geography_kind county, county_vintage 2020, forecast_content_sha256 3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173). The release's constraints file is attached to https://github.com/PolicyEngine/policyengine.py/releases/tag/6.0.0.

Stacked on #3834

This branch is cut from #3834's head 466740d589017df0eb76eb09c59672b806047058, not from master, and targets that branch. #3834 is still open and unmerged, and the bump depends on it: check-policyengine-bundle-supported.sh on master has neither extract_calculator_version nor --check-installed-spm, so on master a registered 6.0.0 worker with empty spm_capabilities would pass the release gate and every canonical economy request would then fail with SPM_CONFIGURATION_UNAVAILABLE. Nothing on #3834's branch was touched here. When #3834 merges, retarget this PR to master.

Why the automatic updater did not do this

The wrapper's consumer-update dispatch reached this repository at 2026-09-15T13:43:43Z and the resulting run failed: https://github.com/PolicyEngine/policyengine-api/actions/runs/34976880739. update-policyengine-package.sh rewrites only the policyengine[models] pin and then runs uv lock --upgrade-package policyengine, which cannot resolve — 6.0.0's models extra itself pins spm-calculator==1.0.0 while this project separately pins ==0.3.1:

Because policyengine[models]>=6.0.0 depends on spm-calculator==1.0.0 and your
project depends on policyengine[models]==6.0.0, we can conclude that your
project depends on spm-calculator==1.0.0. And because your project depends on
spm-calculator==0.3.1 ... your project's requirements are unsatisfiable.

No auto/update-policyengine-bundle-6.0.0 PR exists, on this repository or on policyengine-sim-api (whose dispatch, run 34976880733, failed the same way). This PR is the change the updater could not make; there is nothing to close. The earlier stale auto PRs (#3828, #3809, #3805, #3804) are untouched and would hit the same resolution failure if retriggered.

Changes

File Change
pyproject.toml policyengine[models]==5.2.0==6.0.0; spm-calculator==0.3.1==1.0.0
docker/Dockerfile the same two pins on the single pip install line
uv.lock refreshed from PyPI only
tests/unit/test_cloud_run_deploy_scripts.py image-pin assertions follow the calculator to 1.0.0; the test is renamed from ..._for_the_legacy_country_bundle to ..._for_the_canonical_country_bundle
tests/unit/test_bundle_update_pins.py the hermetic updater fixture now represents the post-bump pins, so it still documents that the updater moves the wrapper pin and leaves the calculator pin alone
tests/unit/test_country_spm.py housing-cap fixtures (below)
tests/integration/test_v2_catalog_installed.py catalog entity counts re-pinned to the 6.0.0 models (below)
docs/canonical-spm.md the assistance-only housing-cap qualification
changelog.d/canonical-bundle-6-0-0.changed.md fragment
tests/fixtures/spm.py (new) + 13 test files the unit suite's doubles now answer the canonical SPM contract (below)

Both pip paths move together because Cloud Run and the published GHCR image install with pip and do not read uv.lock; leaving the standalone calculator pin behind would let those images resolve a calculator the country model cannot use.

Resolved wheel digests in the refreshed lock, each checked against the release manifest:

Package Version Wheel sha256
policyengine 6.0.0 ac51a637881744703939d661c94d843aa773e363bf2ba1e418b26592fa210aec
policyengine-us 2.2.1 0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6
spm-calculator 1.0.0 e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305

spm-calculator resolved to 1.0.0 and not the later 1.0.0.post1, which country 2.2.1's >=1.0.0,<=1.0.0.post1 range would also admit; the installed dist-info is spm_calculator-1.0.0.dist-info. policyengine-core moves 3.30.1 → 3.32.5 and policyengine-uk was already at the manifest's 2.90.2. The lock's own policyengine-api version also catches up from 3.54.2 to pyproject's 3.56.1, which had drifted on the base branch.

Housing-cap fixtures

The SPM housing-cap tests exercised the geography/year requirement with households receiving no housing assistance, where the cap is not the reason the measurement is needed. They now request a controlled positive award where the requirement is the point, and assert that the unassisted case succeeds — 200, zero housing resource, empty spm_provenance years and geographies. docs/canonical-spm.md records the corresponding contract: ordinary household_benefits and household_net_income count the actual housing_assistance rather than the capped SPM resource, so they need no SPM geography and do not move with a valid selection.

These tests skip on a legacy install (pytest.skip("Requires the coordinated canonical US model and calculator source")), so they are silent in CI until the canonical bundle is installed. They were run against an installed canonical bundle instead: 87 passed for the file, 26 passed, 0 skipped for the housing subset, on policyengine 6.0.0 / core 3.32.5 / us 2.2.1 / uk 2.90.2 / spm-calculator 1.0.0, CPython 3.14.4 arm64.

Installed-catalog entity counts

tests/integration/test_v2_catalog_installed.py bounds the catalog the v2 seed job
publishes by asserting exact entity counts, and those counts are a property of the
installed country models. policyengine-us 1.764.6 → 2.2.1 moves four of them:

Entity 5.2.0 bundle 6.0.0 bundle
variables 6,649 7,046
parameter_nodes 27,813 29,118
parameters 99,006 103,705
parameter_values 1,172,130 1,192,826

models, model_versions, datasets and regions are unchanged, as are the dataset
names and the US fallback summaries the same test asserts. The new numbers were
produced twice and agree exactly: by this repository's own Cross-database integration tests job on commit d7cc47e3, and locally against an installed canonical bundle. The
test now passes locally (1 passed), and the Cross-database integration tests check
passes in CI on this head after failing on the previous one.

Status: draft, and one thing blocks it

1. The bundle-supported check cannot pass yet. pr.yml runs check-policyengine-bundle-supported.sh --if-changed-from-base, which skips only when the wrapper pin, the calculator pin and the SPM integration files are all unchanged from the base. Both pins move here, so the check runs and asks the simulation gateway whether the 6.0.0 bundle is deployed. It is not. The check ran and failed with, verbatim:

Expected PolicyEngine .py bundle: 6.0.0

ERROR: PolicyEngine .py bundle 6.0.0 is NOT deployed in the simulation API
Available PolicyEngine versions:
4.18.3  4.18.5  4.18.7  4.18.8  4.18.9  4.20.3  4.22.0  5.2.0  latest

The deployed simulation API tops out at 5.2.0, and no canonical PR is open on policyengine-sim-api. This check is expected to fail until the simulation API's 6.0.0 worker is deployed. Docker, Cloud Run container, Test environment variables and Test all declare needs: on it, and all four did skip.

2. The canonical bundle turns 95 existing unit tests red. Fixed in 7d357a2 — see below.

Canonical-bundle test doubles

A certified bundle makes normalize_spm_selection resolve a measurement for every US request where the legacy bundle returned None, which activates three boundaries that were previously unreachable: the API requires the selected worker to advertise a matching capability before it submits, it hands the measurement to the country package, and it refuses a worker result or a stored household calculation that carries no receipt for what was measured. Ninety-five unit tests were written while those boundaries were dormant and their doubles answer none of them — plain MagicMock gateways return an attribute rather than a capability, mocked /versions surfaces serve no registry, country doubles accept only the two-argument calculate, and worker results carry no provenance. On eb4f46e3 the CI-order suite was 95 failed, 2186 passed; it is now 2286 passed, 0 failed.

The contract now lives once in tests/fixtures/spm.py: the worker capability, the gateway /versions registry document, the country provenance receipt, the measurement arguments a country package receives, and the request options the service resolves. Every value is read from the installed bundle manifest through normalize_spm_selection rather than transcribed, so the doubles follow the pinned bundle instead of becoming a second copy of it that the next bump would contradict — no test file added here names an artifact hash. On a bundle whose model predates the contract each helper returns the legacy shape, and the economy-service cache-identity constants are then byte-identical to the ones this commit replaces (checked by re-importing the fixture module with the selection forced to None and diffing against eb4f46e3).

Tests that assert legacy behaviour by name pin an uncertified bundle rather than inheriting one from whatever is installed: test_a_model_this_build_cannot_load_reads_as_no_canonical_model (whose docstring already says "uncertified bundle"), test_actual_settings_validator_rejects_explicit_settings_on_legacy_worker (a legacy manifest describes a legacy install only together with the model it names), test_legacy_country_requests_do_not_receive_spm, and SimulationAPIModal's TestRun / TestRunBudgetWindowBatch, which assert the legacy gateway body — an explicit data artifact revision dropped, no measurement attached. The canonical translation of the same payloads is already covered against real HTTP by tests/unit/services/test_worker_spm.py::test_actual_entrypoint_http_preserves_selection_and_data_version and …::test_canonical_direct_submission_forwards_the_validated_default_bundle, so nothing is left uncovered. tests/unit/test_spm_settings.py gains the certified counterpart of the first of those (5 new cases): whatever stops the country import on a certified bundle must reach the caller as a typed SPM_CONFIGURATION_UNAVAILABLE rather than a 500.

No assertion was weakened or deleted, and nothing is skipped or xfailed. Three expectations changed, because the canonical contract makes the old ones wrong:

Test Old expectation New expectation, and why
test__given_request_context__then_all_calls_forward_request_id the exact set of gateway paths a sequence of calls touches the set gains /versions: a certified bundle reads the worker registry before each submission, and the test's subject is that every call carries the request id
test__given_no_previous_impact__creates_new_simulation the options written with the start claim are the options the caller sent the resolved options: the service merges the measurement into them before anything is hashed, claimed or submitted, which is also why the cache-identity constants gain an spm segment
test_household_under_policy_calculates_and_caches_json_as_an_object the country is called with a household and a policy alone the call also carries spm and spm_requested, which a certified bundle always passes

Two doubles were malformed rather than incomplete under the canonical contract and are now well-formed. Budget-window results declared a three-year window while carrying zero or one annual impacts, which the API reads as a window with incomplete receipts. And test__given_bundle_default_dataset_name__canonicalizes_setup_identity built setup options against the module-level live HTTP client, so it attempted a real network call once the capability probe became reachable; it now injects a gateway double.

By file, the 95 that were failing: tests/unit/services/test_economy_service.py 50, tests/unit/libs/test_simulation_entrypoint.py 13, tests/unit/routes/test_economy_submission_identity.py 7, tests/unit/test_spm_settings.py 5, tests/unit/routes/test_calculate_deprecated_inputs.py 5, tests/unit/services/test_household_calculation_service.py 4, tests/unit/routes/test_calculate_error_statuses.py 3, tests/unit/services/test_worker_spm.py 2, tests/unit/routes/test_household_and_user_policy_orm_routes.py 2, tests/contract/test_simulation_gateway_contract.py 2, tests/unit/routes/test_canonical_spm.py 1, tests/integration/test_budget_window_in_flight_dedupe.py 1.

This remains local evidence only: the Test job still declares needs: ensure-policyengine-bundle-supported-by-simulation-api, so the unit suite does not run on this PR until a 6.0.0 worker exists. That the bundle was the cause is settled by CI rather than inferred: #3834's Test job on head 466740d — the exact code this branch is cut from, with the 5.2.0 pins — passed in 7m18s, as did its Cross-database integration tests.

Database preflight (G4) — not run, credentials unavailable

This bump must run the ordinary v2 seed job. catalog_selection.py:70-119 selects the catalog row where TaxBenefitModelVersion.version equals the running wrapper version — importlib.metadata.version("policyengine"), so 6.0.0 after this change — and raises MetadataCatalogUnavailableError when it is absent, so v2 metadata routes fail until a 6.0.0 catalog is published. The no-seed branch is not available here.

The read-only preflight below could not be run from this session: the v2 catalog lives in Supabase (staging kvvwlyzqrfpvrxinmlwt, production kvrifaviwhzjztcbrfpy) behind the V2_MIGRATION_DATABASE_URL / V2_DATA_WRITE_DATABASE_URL environment secrets, which are not readable. Someone holding those, or Supabase project access to those refs, should run against both staging and production, read-only:

-- 1. Schema is at the revision publish_catalog requires (publication.py:35,59-70).
--    Must return exactly one row, '724b1b11a33e'.
SELECT version_num FROM alembic_version;

-- 2. No 6.0.0 catalog exists yet, and the serving 5.2.0 rows do.
SELECT m.name, v.version
FROM tax_benefit_model_versions v
JOIN tax_benefit_models m ON m.id = v.model_id
WHERE m.name IN ('policyengine-us', 'policyengine-uk')
ORDER BY m.name, v.version;

-- 3. Baseline for the protected-table equality assert (publication.py:43-47,116-122).
SELECT
  (SELECT count(*) FROM dataset_versions) AS dataset_versions,
  (SELECT count(*) FROM simulations)      AS simulations,
  (SELECT count(*) FROM reports)          AS reports,
  (SELECT count(*) FROM report_runs)      AS report_runs;

Expected: (1) exactly 724b1b11a33e, so the seed job's alembic upgrade head is a no-op and the only writes are the additive catalog; (2) 5.2.0 present for both countries and no 6.0.0 row; (3) counts recorded, to be compared after the seed — publication runs in one transaction under a transaction-scoped advisory lock, publishes only absent country versions, asserts existing ones match, and aborts if any of those four counts changed. Because selection is keyed on the running version, the serving 5.2.0 revision keeps reading its own row throughout.

Verification

Local, in an installed canonical environment (policyengine 6.0.0, core 3.32.5, us 2.2.1, uk 2.90.2, spm-calculator 1.0.0; CPython 3.14.4 arm64; uv pip check clean):

Command Result
pytest tests/unit/test_country_spm.py 87 passed, 0 skipped (exit 0)
pytest -k "assisted or zero_award or unknown_county or requires_geography" 26 passed, 0 skipped (exit 0)
pytest tests/unit/test_bundle_update_pins.py tests/unit/test_cloud_run_deploy_scripts.py 116 passed (exit 0)
RUN_V2_CATALOG_COMPATIBILITY=1 pytest tests/integration/test_v2_catalog_installed.py 1 passed (exit 0)
CI-order suite, -q 2286 passed, 0 failed (154.87s, exit 0) — was 95 failed, 2186 passed on eb4f46e3
make test's exact command (MAX_HOUSEHOLDS=1000 coverage run -a --branch -m pytest tests/to_refactor tests/unit tests/contract tests/integration/test_budget_window_in_flight_dedupe.py --disable-pytest-warnings) 2286 passed (335.34s, exit 0); coverage xml -i exit 0
uv run --frozen --extra dev mypy (make typecheck-v2) Success: no issues found in 83 source files
ruff format --check . 445 files already formatted (also clean under ruff 0.16.7, the version CI installs)
ruff check . All checks passed!
python scripts/run_quality_guards.py migration-contracts: passed
python scripts/export_migration_contracts.py 13 workflows / 50 requests; docs/generated unchanged

2286 is 2281 + 5: the five new certified-bundle cases in tests/unit/test_spm_settings.py.

CI on head 7d357a29 (run 34998954393), all settled:

Check Result
Ensure PolicyEngine bundle is supported by simulation API fail — expected, see blocker 1
Alembic v1 qualification / Alembic MySQL lifecycle pass
Alembic v2 qualification / V2 Postgres migration lifecycle pass
Check changelog fragment pass
Cross-database integration tests pass
Lint pass
Quality guards pass
Type-check API v2 pass
codecov/patch pass
codecov/project fail — also failed on eb4f46e3. Only the Cross-database integration tests job uploads coverage while Test is gated off, so project coverage is reported from a fraction of the suite. It clears with the same gate as blocker 1.
Docker · Cloud Run container · Test environment variables · Test skipping (all needs: the failing gate)

Not verified here: any live staging or production state, the deployed worker's spm_capabilities, and the database preflight above.

🤖 Generated with Claude Code

The wrapper's 6.0.0 release binds policyengine-core 3.32.5, policyengine-us
2.2.1, policyengine-uk 2.90.2 and spm-calculator 1.0.0, and its manifest
certifies the SPM measurement with scenario ce_trend, county geography, county
vintage 2020 and forecast content sha256 3d86d5c4. Both pip build paths move
together: Cloud Run and the published GHCR image install with pip and do not
read uv.lock, so the separate spm-calculator pin has to move with the wrapper
or the image resolves a calculator the country model cannot use. The lock was
refreshed from PyPI only; the resolved wheels are ac51a637 (policyengine),
0993a6c7 (policyengine-us) and e354937a (spm-calculator, not the later
1.0.0.post1 that country 2.2.1's range would also admit), and the lock's own
project version catches up to pyproject's 3.56.1.

The automatic bundle updater cannot make this change. It rewrites only the
policyengine[models] pin and then runs uv lock, and 6.0.0's models extra pins
spm-calculator 1.0.0 against this project's standalone 0.3.1, which is
unsatisfiable. Its dispatched run for 6.0.0 failed on exactly that resolution
and opened no PR.

The housing-cap tests exercised the SPM geography requirement with households
that receive no housing assistance, where the cap is not the reason the
measurement is needed. They now request a controlled positive award where the
requirement is the point, and assert the unassisted case succeeds with a zero
housing resource, no measurement-year receipts and no geography. Ordinary
household_benefits and household_net_income count the actual award rather than
the capped SPM resource, so they need no SPM geography at all and do not move
with a valid selection; the contract document says so. These tests skip on a
legacy install, so they were run against an installed canonical bundle rather
than relied on in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/integration/test_v2_catalog_installed.py bounds the catalog the v2 seed
job publishes by asserting exact entity counts, and those counts are a property
of the installed country models. policyengine-us 1.764.6 to 2.2.1 moves four of
them: variables 6,649 to 7,046, parameter nodes 27,813 to 29,118, parameters
99,006 to 103,705 and parameter values 1,172,130 to 1,192,826. Models, model
versions, datasets and regions are unchanged, as are the dataset names and the
US fallback summaries the same test asserts.

The new numbers were produced twice from the canonical bundle and agree exactly:
by this repository's own Cross-database integration job on the previous commit,
and locally against an installed policyengine 6.0.0 environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 48.30%. Comparing base (466740d) to head (7d357a2).

❗ There is a different number of reports uploaded between BASE (466740d) and HEAD (7d357a2). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (466740d) HEAD (7d357a2)
2 1
Additional details and impacted files
@@                            Coverage Diff                             @@
##           max/spm-api-cache-capability-20260912    #3837       +/-   ##
==========================================================================
- Coverage                                  88.08%   48.30%   -39.78%     
==========================================================================
  Files                                        180      165       -15     
  Lines                                      10957     9832     -1125     
  Branches                                    1934     1683      -251     
==========================================================================
- Hits                                        9651     4749     -4902     
- Misses                                       778     4687     +3909     
+ Partials                                     528      396      -132     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The canonical PolicyEngine 6.0.0 bundle certifies an SPM measurement, so
`normalize_spm_selection` resolves a selection for every US request where a
legacy bundle returned None. Three boundaries that were unreachable before
then activate on every request: the API requires the selected worker to
advertise a matching capability before it submits, it hands the measurement
to the country package, and it refuses a worker result or a stored household
calculation that carries no receipt for what was measured. Ninety-five unit
tests were written while those boundaries were dormant, and their doubles
answer none of them: plain MagicMock gateways return an attribute instead of
a capability, mocked `/versions` surfaces serve no registry, country doubles
accept only the two-argument call, and worker results carry no provenance. On
the previous commit the CI-order suite was 95 failed, 2186 passed under the
6.0.0 pins; it is now 2286 passed, 0 failed.

The doubles now answer the real contract, held in one place in
`tests/fixtures/spm.py`: the worker capability, the gateway `/versions`
registry document, the country provenance receipt, the measurement arguments
a country package receives, and the request options the service resolves.
Every value is read from the installed bundle manifest through
`normalize_spm_selection` rather than transcribed, so a double follows the
pinned bundle instead of becoming a second copy of it that the next bump
would contradict, and none of these files now names an artifact hash. On a
bundle whose model predates the contract every helper returns the legacy
shape; the economy-service cache-identity constants are then byte-identical
to the ones this commit replaces.

Tests that assert legacy behaviour by name pin an uncertified bundle instead
of inheriting one from whatever is installed. That is
`test_a_model_this_build_cannot_load_reads_as_no_canonical_model`, whose
docstring already says "uncertified bundle";
`test_actual_settings_validator_rejects_explicit_settings_on_legacy_worker`,
where a legacy manifest describes a legacy install only together with the
model it names; `test_legacy_country_requests_do_not_receive_spm`; and
`SimulationAPIModal`'s `TestRun` and `TestRunBudgetWindowBatch`, which assert
the legacy gateway body — an explicit data artifact revision dropped, no
measurement attached. The canonical translation of the same payloads is
already covered against real HTTP in `tests/unit/services/test_worker_spm.py`.
`test_spm_settings.py` gains the certified counterpart of the first of those:
whatever stops the country import on a certified bundle must reach the caller
as a typed configuration failure rather than a 500.

Three expectations changed rather than doubles, because the canonical contract
makes the old ones wrong.

`test__given_request_context__then_all_calls_forward_request_id` asserted the
exact set of gateway paths a sequence of calls touches. A certified bundle
reads the worker registry before each submission, so `/versions` joins that
set; the test's subject is that every call carries the request id, and the
capability probe is one of them.

`test__given_no_previous_impact__creates_new_simulation` asserted that the
options written with the start claim are the options the caller sent. The
service resolves the measurement into the options before anything is hashed,
claimed or submitted, so the stored row records what was submitted; it now
asserts the resolved options. The same resolution is why the cache-identity
constants gain an `spm` segment.

`test_household_under_policy_calculates_and_caches_json_as_an_object`
asserted that the country is called with a household and a policy alone. A
certified bundle also passes the resolved measurement and whether the saved
household requested one, so the expected call carries both.

Two doubles were malformed rather than incomplete under the canonical
contract and are now well-formed: budget-window results that declared a
three-year window while carrying zero or one annual impacts, which the API
reads as a window with incomplete receipts; and
`test__given_bundle_default_dataset_name__canonicalizes_setup_identity`,
which built setup options against the module-level live HTTP client and so
attempted a real network call once the capability probe became reachable.

Verified locally against an installed canonical environment (policyengine
6.0.0, core 3.32.5, us 2.2.1, uk 2.90.2, spm-calculator 1.0.0; CPython
3.14.4 arm64). No assertion was weakened or removed, and nothing is skipped
or xfailed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant