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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/check-policyengine-bundle-supported.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
87 changes: 69 additions & 18 deletions .github/find-api-model-versions.py
Original file line number Diff line number Diff line change
@@ -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"),
}


Expand All @@ -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.")
Expand Down
13 changes: 12 additions & 1 deletion .github/request-simulation-model-versions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,6 +46,10 @@ while [ $# -gt 0 ]; do
UK_VERSION="$2"
shift 2
;;
--check-installed-spm)
CHECK_INSTALLED_SPM=1
shift
;;
-h|--help)
usage
;;
Expand All @@ -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"
Expand Down Expand Up @@ -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
28 changes: 20 additions & 8 deletions .github/scripts/update-policyengine-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"

Expand Down Expand Up @@ -92,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
Expand All @@ -110,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:
Expand All @@ -121,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
Expand Down Expand Up @@ -150,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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -395,6 +397,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

Expand Down
1 change: 1 addition & 0 deletions changelog.d/policyengine-6-1-2.changed.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
57 changes: 49 additions & 8 deletions docs/canonical-spm.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading