Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
21624a5
Test reading a stored dynamic's values and combining them with a poli…
anth-volk Nov 12, 2026
f437005
Read a stored dynamic's values, and combine them with a policy's
anth-volk Nov 12, 2026
f86250f
Test the economy simulation and report records, and the choice of a r…
anth-volk Nov 12, 2026
b1dd697
Create economy simulations, reports and their runs, and choose a run …
anth-volk Nov 12, 2026
ebff4f8
Test the economy store's reads and writes, each in its own transactio…
anth-volk Nov 12, 2026
9668c72
Read and write economy simulations and reports, each in its own trans…
anth-volk Nov 12, 2026
40c0691
Test POST /reports and POST /simulations, and the economy calls they …
anth-volk Nov 12, 2026
d570a2a
Create economy reports and simulations through POST /reports and POST…
anth-volk Nov 12, 2026
eddc263
Test reading an economy report or simulation with its newest run's st…
anth-volk Nov 12, 2026
eb7002e
Read an economy report or simulation with its newest run's status
anth-volk Nov 12, 2026
daa1f5c
Test the output bucket on every deploy and the staging economy check …
anth-volk Nov 12, 2026
876328c
Name the output bucket on every deploy, and check a staging economy r…
anth-volk Nov 12, 2026
9788790
Check the economy routes through the regenerated simulation client
anth-volk Nov 12, 2026
db02539
Document the economy routes, their deployment and their records
anth-volk Nov 12, 2026
40a84c2
Test review round 1's fixes and refusal details built from the reques…
anth-volk Nov 12, 2026
1eca0d4
Fix review round 1: locate a report side's refusals, and never answer…
anth-volk Nov 12, 2026
fd9b624
Test review round 2's fix: a request ID the call can't carry is repla…
anth-volk Nov 12, 2026
edff811
Fix review round 2: give the Modal call a request ID its context can …
anth-volk Nov 12, 2026
dbfa8ed
Test review round 3's quality findings (failing)
anth-volk Nov 12, 2026
70203a9
Fix review round 3's quality findings
anth-volk Nov 12, 2026
56b7813
Merge remote-tracking branch 'origin/v2plan/c1-8-run-table-coordinati…
anth-volk Nov 12, 2026
92778f1
Test the standalone context through the contract's field, and the dea…
anth-volk Nov 12, 2026
628cd3c
Build the standalone context through C1 · 8's field, and name the las…
anth-volk Nov 12, 2026
31c4c7f
Fix CI: list the new input tables, and check a read run is present be…
anth-volk Nov 12, 2026
0dc29a2
Test that the deadline failure names the latest read, not the first
anth-volk Nov 12, 2026
695b88f
Name a call's runs once: the context's prefix follows the runs it names
anth-volk Nov 12, 2026
4826fa2
Describe the deadline test's 503s and the context's runs argument exa…
anth-volk Nov 12, 2026
3cb8d1f
Merge remote-tracking branch 'origin/v2plan/c1-8-run-table-coordinati…
anth-volk Nov 12, 2026
900917f
Merge main into v2plan/c1-9-economy-simulations-reports
anth-volk Nov 12, 2026
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
7 changes: 7 additions & 0 deletions .github/scripts/deploy-cloud-run-simulation-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ required_variables=(
SIMULATION_SERVICE_DATABASE_URL_SECRET_NAME
MODAL_TOKEN_ID_SECRET_NAME
MODAL_TOKEN_SECRET_SECRET_NAME
V2_SIMULATION_OUTPUT_BUCKET_VALUE
OBSERVABILITY_SERVICE_NAMESPACE
OBSERVABILITY_TRACE_PROJECT_ID
OTEL_EXPORTER_OTLP_ENDPOINT
Expand All @@ -128,6 +129,11 @@ esac
|| fail "TAG is invalid"
[[ "${ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \
|| fail "ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT is invalid"
# The bucket that keeps v2 simulation output files for good: a report
# reuses only a succeeded run whose file is in it. The service compares
# output URIs with it and never reads it.
[[ "${V2_SIMULATION_OUTPUT_BUCKET_VALUE}" =~ ^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$ ]] \
|| fail "V2_SIMULATION_OUTPUT_BUCKET_VALUE is invalid"
[[ "${ENTRYPOINT_MIN_INSTANCES}" =~ ^[0-9]+$ ]] \
|| fail "ENTRYPOINT_MIN_INSTANCES must be a non-negative integer"
[[ "${ENTRYPOINT_MAX_INSTANCES}" =~ ^[0-9]+$ ]] \
Expand Down Expand Up @@ -195,6 +201,7 @@ jq -n '
POLICYENGINE_OTEL_GOOGLE_AUDIENCE: env.POLICYENGINE_OTEL_GOOGLE_AUDIENCE,
SIMULATION_V2_MODAL_ENVIRONMENT: env.MODAL_ENVIRONMENT,
SIMULATION_V2_MANIFEST_NAME: "simulation-api-v2-version-manifest",
V2_SIMULATION_OUTPUT_BUCKET: env.V2_SIMULATION_OUTPUT_BUCKET_VALUE,
STAGE12_ENABLED: (
if env.DEPLOY_STAGE12_V2 == "true" then env.STAGE12_ENABLED_VALUE else "0" end
)
Expand Down
136 changes: 136 additions & 0 deletions .github/scripts/simulation-staging-economy-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/bin/bash
# Create a small economy report through a deployed Simulation Entrypoint, and
# require its run to succeed: current law against current law for Wales in
# 2026, which needs no stored policy.
# Usage: simulation-staging-economy-check.sh <base-url>
# Required env vars: the test client's SIMULATION_TEST_AUTH_ISSUER,
# SIMULATION_TEST_AUTH_AUDIENCE, SIMULATION_TEST_AUTH_CLIENT_ID and
# SIMULATION_TEST_AUTH_CLIENT_SECRET.
# Optional: ECONOMY_CHECK_DEADLINE_SECONDS (1800) and
# ECONOMY_CHECK_POLL_SECONDS (15).
# The access token is sent from a file and the client secret on standard
# input, so neither appears on a command line. The submission, which isn't
# asked again, may take 60 seconds, Cloud Run's own limit; every other
# request 30. A read that gets no answer, a 429 or any 5xx is asked again
# until the deadline.

set -euo pipefail

fail() {
printf 'Staging economy check failed: %s\n' "$1" >&2
exit 1
}

base_url="${1:?Simulation Entrypoint base URL is required}"
base_url="${base_url%/}"
deadline_seconds="${ECONOMY_CHECK_DEADLINE_SECONDS:-1800}"
poll_seconds="${ECONOMY_CHECK_POLL_SECONDS:-15}"
request_timeout_seconds=30
submission_timeout_seconds=60
uuid_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'

for variable_name in SIMULATION_TEST_AUTH_ISSUER SIMULATION_TEST_AUTH_AUDIENCE \
SIMULATION_TEST_AUTH_CLIENT_ID SIMULATION_TEST_AUTH_CLIENT_SECRET; do
[[ -n "${!variable_name:-}" ]] || fail "${variable_name} must be set"
done
[[ "${deadline_seconds}" =~ ^[0-9]+$ ]] \
|| fail "ECONOMY_CHECK_DEADLINE_SECONDS must be a whole number of seconds"
[[ "${poll_seconds}" =~ ^[0-9]+$ ]] \
|| fail "ECONOMY_CHECK_POLL_SECONDS must be a whole number of seconds"

work_directory="$(mktemp -d)"
trap 'rm -rf "${work_directory}"' EXIT
chmod 700 "${work_directory}"

# Send one request, allowing it <seconds>; write its body to <name>.json and
# print its HTTP status. It fails when no answer arrives.
send() {
local name="$1"
local seconds="$2"
shift 2
curl --silent --show-error --max-time "${seconds}" \
--output "${work_directory}/${name}.json" --write-out '%{http_code}' "$@"
}

token_status="$(
jq -n \
--arg client_id "${SIMULATION_TEST_AUTH_CLIENT_ID}" \
--arg client_secret "${SIMULATION_TEST_AUTH_CLIENT_SECRET}" \
--arg audience "${SIMULATION_TEST_AUTH_AUDIENCE}" \
'{client_id: $client_id, client_secret: $client_secret, audience: $audience, grant_type: "client_credentials"}' \
| send token "${request_timeout_seconds}" --request POST --header 'Content-Type: application/json' \
--data @- "${SIMULATION_TEST_AUTH_ISSUER%/}/oauth/token"
)" || fail "the token request got no answer"
[[ "${token_status}" == "200" ]] || fail "the token request answered ${token_status}"
access_token="$(jq -r '.access_token // empty' "${work_directory}/token.json")"
[[ -n "${access_token}" ]] || fail "the token response has no access_token"
authorization_header="${work_directory}/authorization-header"
printf 'Authorization: Bearer %s\n' "${access_token}" > "${authorization_header}"

submission_status="$(
jq -n '{
country: "uk",
report_type: "economy",
label: "Staging economy check",
year: 2026,
region_code: "country/wales",
baseline: {policy_id: null, dynamic_id: null},
reform: {policy_id: null, dynamic_id: null},
include_cliffs: false
}' \
| send submission "${submission_timeout_seconds}" --request POST --header "@${authorization_header}" \
--header 'Content-Type: application/json' --data @- \
"${base_url}/reports"
)" || fail "POST /reports got no answer; it may have created a report"
[[ "${submission_status}" == "201" ]] \
|| fail "POST /reports answered ${submission_status}: $(cat "${work_directory}/submission.json")"
submission="${work_directory}/submission.json"
report_id="$(jq -r '.report_id // empty' "${submission}")"
report_run_id="$(jq -r '.report_run_id // empty' "${submission}")"
[[ "${report_id}" =~ ${uuid_pattern} ]] || fail "the submission named no report"
[[ "${report_run_id}" =~ ${uuid_pattern} ]] || fail "the submission named no report run"
printf 'Created report %s, run %s (baseline reused: %s, reform reused: %s)\n' \
"${report_id}" "${report_run_id}" \
"$(jq -r '.baseline_reused' "${submission}")" "$(jq -r '.reform_reused' "${submission}")"

started_at="${SECONDS}"
# What the last read found, for the deadline's failure message.
last_read="nothing yet"
while true; do
report_body="${work_directory}/report.json"
if ! read_status="$(
send report "${request_timeout_seconds}" --header "@${authorization_header}" \
"${base_url}/reports/${report_id}"
)"; then
last_read="it got no answer"
printf 'GET /reports/%s got no answer; asking again\n' "${report_id}" >&2
elif [[ "${read_status}" == "200" ]]; then
newest_run_id="$(jq -r '.run.run_id // empty' "${report_body}")"
[[ "${newest_run_id}" == "${report_run_id}" ]] \
|| fail "report ${report_id}'s newest run is ${newest_run_id:-missing}, not the created run ${report_run_id}"
run_status="$(jq -r '.run.status' "${report_body}")"
last_read="the run was ${run_status}"
case "${run_status}" in
succeeded)
printf 'Report run %s succeeded on .py %s with %s\n' "${report_run_id}" \
"$(jq -r '.run.policyengine_version' "${report_body}")" \
"$(jq -r '.run.country_package_version' "${report_body}")"
exit 0
;;
failed)
fail "report run ${report_run_id} failed: $(jq -r '.run.error_message // "no error message"' "${report_body}")"
;;
pending | running) ;;
*) fail "report run ${report_run_id} has an unknown status: ${run_status}" ;;
esac
elif [[ "${read_status}" == "429" || "${read_status}" == 5?? ]]; then
# The service, or Cloud Run's front end in its place, can't answer now.
last_read="it answered ${read_status}"
printf 'GET /reports/%s answered %s; asking again\n' "${report_id}" "${read_status}" >&2
else
fail "GET /reports/${report_id} answered ${read_status}: $(cat "${report_body}")"
fi
((SECONDS - started_at < deadline_seconds)) \
|| fail "report run ${report_run_id} did not finish within ${deadline_seconds} seconds (last read: ${last_read})"
sleep "${poll_seconds}"
done
13 changes: 13 additions & 0 deletions .github/scripts/simulation-staging-economy-ready.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash
# Require the staging economy check to have succeeded in a staging
# deployment, and to have been skipped in any other.
# Required env vars: REQUESTED ("true" in a staging deployment) and
# CHECK_RESULT (the staging_economy_check job result).

set -euo pipefail

if [[ "${REQUESTED}" == "true" ]]; then
test "${CHECK_RESULT}" = "success"
else
test "${CHECK_RESULT}" = "skipped"
fi
46 changes: 45 additions & 1 deletion .github/workflows/simulation-deploy.reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ jobs:
STAGE12_DATABASE_URL_SECRET_NAME: ${{ vars.STAGE12_DATABASE_URL_SECRET_NAME }}
MODAL_TOKEN_ID_SECRET_NAME: ${{ vars.MODAL_TOKEN_ID_SECRET_NAME }}
MODAL_TOKEN_SECRET_SECRET_NAME: ${{ vars.MODAL_TOKEN_SECRET_SECRET_NAME }}
V2_SIMULATION_OUTPUT_BUCKET_VALUE: ${{ vars.V2_SIMULATION_OUTPUT_BUCKET }}
run: .github/scripts/deploy-cloud-run-simulation-entry.sh

- name: Resolve exact candidate metadata
Expand Down Expand Up @@ -1252,6 +1253,43 @@ jobs:
SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }}
run: .github/scripts/simulation-staging-household-check.sh "${{ needs.deploy_entrypoint.outputs.candidate_url }}"

staging_economy_check:
name: Create a staging economy report through the candidate
# Every staging deployment creates a small economy report, current law
# against current law for Wales in 2026, through the candidate's
# POST /reports, and requires its run to succeed. The routes use only
# the v2 manifest's default worker, so the check runs after the default
# has moved to the release's worker (or, in a deployment without Stage
# 12, with the default as it is). It calls the tagged candidate URL,
# which serves this release's revision whether or not it was promoted.
# The report's rows are written in the staging database, and its
# coordinator records the run. Once a .py version's first check has
# succeeded, later checks on it reuse those simulation runs.
if: >-
${{
!cancelled() &&
inputs.deployment_environment == 'staging' &&
needs.deploy_entrypoint.result == 'success' &&
needs.authenticated_test.result == 'success' &&
((inputs.deploy_stage12_v2 && needs.publish_stage12_v2_default.result == 'success') ||
(!inputs.deploy_stage12_v2 && needs.publish_stage12_v2_default.result == 'skipped'))
}}
needs: [deploy_entrypoint, authenticated_test, publish_stage12_v2_default]
runs-on: ubuntu-latest
# Longer than the script's own 30-minute deadline, so the script fails first.
timeout-minutes: 40
environment: ${{ inputs.release_environment }}
steps:
- uses: actions/checkout@v6

- name: Create the staging economy report and require its run to succeed
env:
SIMULATION_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}
SIMULATION_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}
SIMULATION_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }}
SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }}
run: .github/scripts/simulation-staging-economy-check.sh "${{ needs.deploy_entrypoint.outputs.candidate_url }}"

finalize_routing:
name: Finalize the Modal routing publication
# Finish the durable journal after every gate, including failed uploads
Expand Down Expand Up @@ -1310,7 +1348,7 @@ jobs:
# The reporter only reads job results. Journal completion happens before
# it, so a failure or cancellation here cannot strand a publication.
if: ${{ !cancelled() }}
needs: [deploy_entrypoint, update_routing, integration, authenticated_test, promote_entrypoint, publish_latest, finalize_routing, publish_stage12_v2_default, staging_household_check]
needs: [deploy_entrypoint, update_routing, integration, authenticated_test, promote_entrypoint, publish_latest, finalize_routing, publish_stage12_v2_default, staging_household_check, staging_economy_check]
runs-on: ubuntu-latest
defaults:
run:
Expand Down Expand Up @@ -1345,6 +1383,12 @@ jobs:
CHECK_RESULT: ${{ needs.staging_household_check.result }}
run: .github/scripts/simulation-staging-household-ready.sh

- name: Check the staging economy check result
env:
REQUESTED: ${{ inputs.deployment_environment == 'staging' }}
CHECK_RESULT: ${{ needs.staging_economy_check.result }}
run: .github/scripts/simulation-staging-economy-ready.sh

cleanup_entrypoint_revisions:
name: Remove superseded Cloud Run revisions
if: ${{ !cancelled() && inputs.promote_entrypoint && needs.deployment_ready.result == 'success' }}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/c1-9-economy-reports.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Simulation Entrypoint creates economy reports and simulations through `POST /reports` and `POST /simulations`, in the Public API's run tables, and starts their calculation on the v2 coordinator and workers by run ID; a report reuses a matching simulation run that already succeeded. `GET /reports/{report_id}` and `GET /simulations/{simulation_id}` return each record's newest run, and every staging deploy checks a small economy report.
53 changes: 50 additions & 3 deletions libs/policyengine-simulation-persistence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ year's report run names that row and its year, and belongs to the same report.
## Input tables

`input_tables.py` holds read-only models of the Public API's tables a
calculation is read from: `households`, `policies`, `parameters`,
`parameter_values`, `tax_benefit_models` and `tax_benefit_model_versions`, in
`input_metadata`. The service only reads them, with `SELECT`. Each names only
calculation is read from: `households`, `policies`, `dynamics`, `parameters`,
`parameter_values`, `regions`, `datasets`, `tax_benefit_models` and
`tax_benefit_model_versions`, in `input_metadata`. The service only reads them, with `SELECT`. Each names only
the columns the service reads, so they are not models of the whole tables,
and they are kept apart from the models of the tables the service writes
(`service_metadata`) and from the tables those name by `id` alone
Expand Down Expand Up @@ -81,6 +81,50 @@ which commits when its block ends and sets `statement_timeout` with
`set_config`, local to the transaction, so the database ends a statement that
runs too long and the limit never stays on a pooled connection.

## Economy simulations and reports

`economy_run_records.py` holds the simulation service's reads and writes for
an economy report, or a standalone economy simulation. It reads the
country's model (`read_model_id`) and a catalog version's region by code
(`read_region`) and dataset by name or ID (`read_dataset`,
`read_dataset_by_id`). `insert_economy_report` creates, in the caller's
transaction, each side's new simulation and first run, the report naming
both simulations and the reform's policy with its options in `inputs`, and
its first report run, trigger `initial`; every run is `pending` with no
Modal call ID. A side may instead name a run that already succeeded
(`ReportSimulation.reused`), for which nothing is created.
`insert_economy_simulation` creates a standalone simulation and its first
run. A row naming one that does not exist is refused by its foreign key with
SQLSTATE `23503`; `EconomyRecordReferenceError` names the table and column.

`find_reusable_run` returns the run a new report's side may reuse: the
newest, by completion, of the same economy simulation (policy, dynamic,
catalog version, dataset, region and year) that succeeded on the same
country package and .py versions, whose output file is under the output
bucket's `gs://<bucket>/`, that a succeeded report run with the same options
used, and that no report run refused to reuse (an error message starting
`reused_simulation_`). So a submission after a refused reuse calculates the
simulation again.

`record_report_run_call` and `record_simulation_run_call` record a Modal
call's ID, and `fail_unstarted_report_run` and
`fail_unstarted_simulation_run` fail a run at once, each only while it is
`pending` with no call ID; failing a report run also fails the simulation
runs created for it. `economy_run_reads.py` reads a report, or a simulation,
with its newest run's state.

`EconomyRunStore` runs them as the Simulation Entrypoint does, each step in
a transaction of its own: `read_report_inputs` reads the catalog version,
model, region and dataset, each side's policy and dynamic values, and each
side's reusable run together, never giving both sides the same run;
`read_simulation_inputs` does the same for one simulation; the writes and
reads each take their own. A missing region or dataset raises
`UnknownRegionError` or `UnknownDatasetError`, and a report side's refused
policy or dynamic raises `ReportSideError`, naming the side. A create whose
commit fails after its rows were inserted raises
`EconomyRecordOutcomeUnknownError`: PostgreSQL may have applied the commit,
so the rows may exist.

## Reading a policy

`read_policy_reform` (`policy_reform.py`) reads a stored policy as the reform
Expand All @@ -91,6 +135,9 @@ the parameters of the catalog version it was saved on, so a calculation on
any catalog version applies them by name; a parameter the calculation's
version lacks raises `MissingPolicyParametersError`, naming each one, and a
policy the country doesn't have raises `UnknownPolicyError`.
`read_dynamic_reform` reads a stored dynamic the same way (`UnknownDynamicError`
for none), and `combine_reforms` joins a simulation's policy and dynamic
values, refusing a parameter both set (`OverlappingReformError`).

Each period is a range, `YYYY-MM-DD.YYYY-MM-DD`, with the stored dates read
in UTC. A value with no end date follows .py's own rule
Expand Down
Loading
Loading