Skip to content

Run and store v2 economy reports in the Simulation Entrypoint - #974

Draft
anth-volk wants to merge 132 commits into
v2plan/1b-107-result-tablesfrom
v2plan/2a-201-economy-path
Draft

anth-volk wants to merge 132 commits into
v2plan/1b-107-result-tablesfrom
v2plan/2a-201-economy-path

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #973

Same-Repository Draft PR Check

  • This PR is a draft.
  • This PR is from a branch in PolicyEngine/policyengine-sim-api, not a personal fork.

Summary

After this deploys, the Simulation Entrypoint runs and stores whole economy reports on v2 without the old gateway, and a small staging report runs after every staging promotion. No user reaches it yet: the Public API's /v2/reports (a separate PR) is its first caller.

It builds on #951 (stacked on #950, #900 and #898, all on main) and merges two other unmerged PRs' branches with plain merge commits:

One Modal call per simulation run, one per comparison

  • Contract (policyengine_simulation_contract.economy_runs): EconomySimulationCall names one simulation run, its policy's parameter values by name and period, its year, the PolicyEngine.py version, and the region and dataset its catalog version has (CatalogPopulation: the dataset's name, the household column and stored value that select the region, and each state's filter for a US national run). The call returns EconomySimulationOutput: its stored input and output files, the output plan's digest, row identity and bundle provenance. EconomyComparisonCall names a report run and both runs' policies and stored outputs (URI and SHA-256); it returns a SingleYearMacroOutput.
  • Simulation calls (run_economy_simulation_us and _uk in the v2 Modal app): each checks that the call names the PolicyEngine.py version and a dataset the installed release has, plans the run's output columns (resolve_simulation_output_plan: the columns any report comparing it reads), and calculates it as a Stage 12 worker returning its result: no database rows, input and output stored under keys naming their SHA-256 in the Stage 12 artifact layout, a US national run in its 20 region-group segments. A simulation run has no report role; its Stage 12 input names it the baseline.
  • Comparison calls (compare_economy_simulations, on the coordinator image): read both outputs, refuse one whose bytes differ from the digest sim recorded, require both to hold the same households and the columns the pair's comparison reads, compute the comparison with the Stage 12 comparison code, and return the result. They write nothing.
  • Each top-level function reports its end: it wraps its work in reporting_call_end, which starts report_call_ended with the call ID and the run ID when the work returns or raises an Exception. A run ID that can be read from an otherwise malformed payload is still reported, so sim records the failure without waiting for the safety check. The unit tests check that each function is declared under the name sim starts it by; nothing checks a decorated function's __name__ at import, which the real Modal package does not allow.

Regions and datasets from the catalog

The entrypoint reads the run's region and dataset rows of its catalog version (CatalogReader, persistence library) and sends the dataset's name and the region's row filter. A simulation carrying a catalog population (options["catalog_population"]) loads that dataset and keeps the households its filter selects; the executor never calls policyengine.py's get_region or region registry for it. A stored value written as an integer (a state's FIPS number, a district's GEOID) is compared as one; a JSON list (England's nine regions) selects any of its values. A US national run's segments are scoped to their states' catalog filters, and every state the catalog filters is calculated. A region below the country without a household filter is refused. Stage 12 runs carry no catalog population and resolve their regions as before; no main-path code is used.

The Public API stores regions.region_type as the PostgreSQL enum v2_region_type, which has no comparison with text values, so the catalog reads compare it cast to text. Policy period dates are read in UTC. A policy value with no end date becomes a period key of its start date alone, which the worker reads as open-ended (a ParameterValue with no end date).

The tests' copies of the Public API's tables, checked in CI

The persistence tests create copies of the Public API tables sim reads and refers to, with only the columns sim uses. A copy that differs from the real table hides bugs: a varchar copy of regions.region_type let the comparison above, which PostgreSQL refuses, pass every test. So:

  • tests/test_public_api_schema.py compares, for every table the copies create, each column's type (an enum by name and values), whether it allows null, and each primary key and unique constraint by name and columns, with a database the Public API's own v2 migration chain built. A column or table added to the copies is compared with no other change, so covering households and policies needs only their columns in tests/public_api_support.py.
  • A new pr.yml job, Public API schema, checks out PolicyEngine/policyengine-api at its default branch, builds its tables with its v2 Alembic chain (.github/scripts/public-api-schema-reference.sh, the same commands and settings as the Public API's own Alembic v2 job), and runs that comparison, which fails rather than skips there. CI complete needs it. It follows the default branch rather than a pinned commit, so a change there to a column sim reads fails sim's next CI run instead of waiting for someone to move a pin; the cost is that such a change turns sim's CI red until the copies (and sim's code, if it reads the column) follow. The script prints the commit it migrated, and a failed comparison names it.
  • Against the Public API's default branch the comparison found the copies differed, and they now match: parameter_values.value_json is jsonb (its baseline revision created it as json, a later one changed it); tax_benefit_model_versions.model_id and version, and policies.tax_benefit_model_version_id, refuse null; and the primary keys and the model name's unique constraint carry the Public API's names.
  • The new rule sim.persistence.public-api-copies-match records this, with a proof: the copies with regions.region_type as varchar(64) are refused, and the current ones accepted. It changes the rules directory, so it needs a core developer's approval.

Runs, reuse and comparisons in the entrypoint

  • EconomyReports (economy_reports; its requests, errors and store protocols are in economy_report_types) finds or creates the report's two economy simulations (so their runs can be reused), creates the report and its run on the catalog version its PolicyEngine.py version names, finds or creates a run of each simulation (reusing one that is unfinished or succeeded within 28 days), and starts the calls of the runs it created. A policy saved on another catalog version is applied by parameter name; a run whose policy changes a parameter its version lacks fails as invalid_input, naming the parameters. Catalog rows a call cannot carry are refused before anything is written. Once both runs have succeeded (at once when both were reused, or when a call's report is recorded) the comparison starts; outputs no longer offered fail it as result_expired.
  • A submission starts only the runs it created. A reused run still pending is started by the submission that created it, which may be a labour supply report asking for more columns; if that submitter stopped, the safety check starts it after ten minutes for every report run comparing it, with the columns any of them needs.
  • A forced start (a labour supply report's, or a forced re-run) always gets an attempt of its own: when it loses the insert of an attempt number to another caller, it adds the attempt after it.
  • A report migrated from v1 without a run is calculated when it is first opened, through a system run. A system run returns the report's newest run when it has one, and RunStore.create_first_report_run records a first report run under a transaction-level advisory lock on the report, so simultaneous first openings share one run. A losing caller starts the runs it created that the shared run compares and leaves its others pending for the safety check.
  • Finished calls are recorded with two new recorders (economy_recorders): a simulation run's input and output files are linked to it (output_files) in the transaction that marks it succeeded; a comparison's result is stored as the result tables' rows.
  • Each safety-check pass also starts the comparisons whose runs have succeeded and the runs a submitter left pending for ten minutes, item by item, and fails a pending run no report run compares.

Routes

POST /v2/economy-reports, GET /v2/economy-reports/{report_id}, GET /v2/economy-reports/by-legacy-id/{legacy_report_id}, POST and GET /v2/economy-reports/{report_id}/runs, and GET /v2/simulations/{simulation_id} (economy_routes). Submissions need simulation:submit and reads simulation:read. A report reads with its newest run, the run's status (not_run for a migrated report without one), its stage while it runs (queued, simulating, outputs, from the run rows) and its error, and, once compared, the result rebuilt from its rows in today's format with UK area names from the catalog. A household report is not found on these routes.

Each route declares its error answers in the OpenAPI document, so the generated client parses them: 404 and 503 as EconomyErrorResponse (detail as text), 422 as EconomyRefusalResponse, whose detail is sim's reason as text or FastAPI's list of refused fields (EconomyRequestFieldError), and, on the two report reads, 500 as EconomyErrorResponse when a succeeded run's stored result cannot be rebuilt. Without the simulation database or the Modal environment the routes answer 503. The generated client gains submit_economy_report, get_economy_report, get_economy_report_by_legacy_id, run_economy_report, list_economy_report_runs and get_simulation; the comparison with the old gateway's contract leaves them out.

Cancellation and pre-emption

  • A segmented US national run cancels every segment call it started when its call stops for any reason: a failed segment or dispatch (an Exception), and Modal's cancellation (InputCancellation) or pre-emption (KeyboardInterrupt), which Let the v2 report coordinator return a typed result to its caller #896's segment runner handles; the economy functions keep that behaviour, and a stopped call sends no report.
  • Pre-emption is never a failure: the interrupted attempt sends no report, Modal runs the input again under the same call ID (which sim's run rows still hold), and the re-run stores its output under its own digest key and reports when it ends.
  • Differs from the original design, following the pre-emption decision recorded in Let the v2 report coordinator return a typed result to its caller #896: a restarted call does not resume a claim or reattach to the calls its interrupted attempt started. It starts over, after its interrupted attempt cancelled them. There is no claim to resume in Modal (sim's run rows keep the call ID), and an identical artifact is reused because its key names its digest.

Staging check

After each staging promotion, the economy_staging_check job submits a report on Wales, current law compared with itself, to the stable URL. When the submission reused a run an earlier deployment finished, it runs the report again with a fresh run, so this deployment's functions calculate it (one simulation call and two comparison calls in that case, one of each otherwise). It waits up to twenty minutes and reads the result back. It needs a staging catalog version for the deployed PolicyEngine.py version. Its failure is shown in the run without holding back the production deployment (continue-on-error).

Deliberate limits, for the reviewer

  • Report options must be empty. An SPM selection or cliff impacts change a simulation's own output, and a run's reuse key (simulation, catalog version, year) cannot tell which it was calculated with. The app's economy requests send neither today.
  • Labour supply responses: a report's comparison reads labour supply columns from both simulations when either policy changes an elasticity. A run plans them only when asked, because in the UK they mean simulating again, so such a report always calculates both runs again and asks for them; other reports reuse runs.
  • UK map positions are not in the region catalog, so a stored result is read back with the areas' names and null x and y.
  • UK-wide and nation reports on a release that routes UK areas to a local-area dataset return no constituency or local-authority lists, as on the main path; the separate local-area breakdown run (LocalAreaBreakdownOutput) is not stored by the result tables yet.
  • Time limits are unchanged: the US simulation call 3,900 s, the UK one 3,000 s, each segment call 3,600 s, the comparison call 1,800 s. A restart does not extend them, and no Modal call waits for the simulation calls: the entrypoint starts the comparison once it has recorded both runs.
  • Overlap with Calculate and store households through the v2 household functions #957 (the household path, on the same base): both add routes, recorders and Modal functions to the same modules (app.py, simulation_database.py, run_dispatch.py, the v2 Modal app and its tests, the deployment workflow). Whichever merges second rebases. The OpenAPI changes here touch only the economy routes and add three schemas, none of which Calculate and store households through the v2 household functions #957 declares.

Environment variables

  • SIMULATION_DATABASE_URL (entrypoint and safety-check job; read by the new routes, recorders and resumption): unchanged wiring from Give the Simulation Entrypoint its own simulation schema, migration chain and database connection #898 and Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950. Provider: Secret Manager, named by the beta/prod variable SIMULATION_DATABASE_URL_SECRET_NAME, mounted by deploy-cloud-run-simulation-entry.sh, and copied at the pinned version into the safety-check job. Needs a new database grant (operator step): the runtime role must read public.tax_benefit_models, public.tax_benefit_model_versions, public.regions, public.datasets, public.parameters and public.parameter_values. Not verified in a deployment.
  • MODAL_TOKEN_ID, MODAL_TOKEN_SECRET (entrypoint and safety-check job; now also used to start economy calls): unchanged wiring from Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950. Provider: Secret Manager, named by MODAL_TOKEN_ID_SECRET_NAME and MODAL_TOKEN_SECRET_SECRET_NAME. Not verified in a deployment.
  • SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT (entrypoint: now also the Modal environment economy calls start in; safety-check job: newly copied from the serving revision). Provider: the reusable workflow input modal_environment, written by deploy-cloud-run-simulation-entry.sh as a plain variable; the safety-check deploy script now copies it with the other plain variables. Verified in the scripts and their tests; not in a deployment.
  • APP_ENVIRONMENT (now also names the deployment in economy artifact paths): unchanged; the workflow input deployment_environment, copied into the job as before.
  • SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID: unchanged from Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950; the reports the economy functions send are checked against it. Its operator step (setting MODAL_WORKSPACE_ID) is still open.
  • Modal functions: STAGE12_ARTIFACT_BUCKET and GOOGLE_APPLICATION_CREDENTIALS_JSON (artifact writes and reads) and HUGGING_FACE_TOKEN (datasets) reach the new functions through the same Modal secrets the Stage 12 workers use (stage12-evaluation-runtime, stage12-evaluation-gcp-credentials and the Hugging Face secret), attached as worker_secrets; MODAL_ENVIRONMENT labels telemetry (set by Modal, with a local fallback). report_call_ended gets SIMULATION_ENTRY_URL from the v2 deploy job, as in Record simulation runs and report runs, and their outcomes when Modal reports a call ended #950. The secrets' contents are not verified here.
  • Public API schema job (new): no secret. SIMULATION_TEST_DATABASE_URL, PUBLIC_API_SCHEMA_DATABASE_URL, SIMULATION_REQUIRE_DATABASE_TESTS and SIMULATION_REQUIRE_PUBLIC_API_SCHEMA are literals in the job's env: (the service container's disposable credentials), and the service gets POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB as literals. The script sets UV_PYTHON=3.12, and V2_MIGRATION_DATABASE_URL and V2_ALEMBIC_DISPOSABLE_TEST for the Public API's Alembic command only, from its input; it writes PUBLIC_API_SCHEMA_COMMIT to GITHUB_ENV, which the runner provides. The checkout uses the automatic github.token under permissions: contents: read, with persist-credentials: false; the Public API's repository is public.
  • Staging check job: SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER, _AUDIENCE, _CLIENT_ID and _CLIENT_SECRET from the beta environment secrets the deployed-auth checks already use (SIMULATION_ENTRYPOINT_AUTH_ISSUER, SIMULATION_ENTRYPOINT_AUTH_AUDIENCE, SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID, SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET), reaching the reusable workflow through secrets: inherit and the job's environment: beta; the stable URL and PolicyEngine.py version from the deploy_entrypoint and prepare job outputs. That the beta secrets exist is not verified here.

Waits for

Testing

On the head commit, locally:

  • contract 396 passed; persistence 227 passed against PostgreSQL 17 (including simultaneous starts that wait on real row locks and simultaneous first report runs); Stage 12 persistence 40 passed; entrypoint 1,049 passed; executor 1,992 passed and 22 skipped; gateway 268 passed;
  • the Public API schema job's steps, run locally against the Public API's default branch at 7de28683f: the migration and the comparison pass. With the copies as they were, the comparison listed 14 differences; with the old varchar(64) copy of regions.region_type, or a json copy of value_json, it fails on that column alone;
  • ruff 0.15.22 (check and format), Pyright on every package, scripts/run_quality_guards.py (including the file-length rule) and the rules tool's tests;
  • every commit on the branch was checked the same way for the packages it changes.

The OpenAPI document was generated from this branch and the Python client generated from it with openapi-python-client 0.29.0. Driven against the app with in-memory stores, the client parses each declared error answer: sim's own 422s and FastAPI's, 404 on every read, and 503 on every route.

An end-to-end run against a migrated PostgreSQL database with Modal replaced by a recording dispatcher submitted a report, recorded both simulation calls and the comparison, and read the result back rebuilt exactly from its rows; a re-run reused the runs.

Not run: anything against a deployed service, Modal app or database, including the staging check.

🤖 Generated with Claude Code

anth-volk and others added 30 commits October 22, 2026 17:00
The main deployment's beta job and the Stage 12 comparison path's staging
deployment now pass promote_entrypoint: true, as production deployments do.
Each staging deployment therefore moves the staging service's stable URL to
the revision it tested, runs the same stable-URL checks as production,
restores the previous revision if they fail, and then deletes the staging
service's older revisions except the promoted one and the one it replaced.

The new rule sim.deploy.staging-promotes-tested-revision requires it: a test
reads every workflow, finds each job that runs the reusable deployment for
staging, and fails unless it passes promote_entrypoint: true. Its proof
fixtures are the beta job before and after this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…build

The beta environment does not restrict which branch a run may come from,
so a manual run of either deployment workflow from a branch other than main
now moves the staging stable URL to that branch's build until the next
deployment from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prod deployment needs the whole beta deployment, which now includes
the revision clean-up that runs after a staging promotion, so a failed
staging clean-up also stops the production deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e staging clean-up

The repository README already says the prod deployment starts only after
beta's revision clean-up succeeds; the entrypoint's DEPLOYMENT.md listed the
beta jobs prod waits for without the clean-up job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage12InvocationContext gains result_delivery: stage12_database (the
default, today's behavior) or return_to_caller, for a report coordinator
that returns its result instead of recording it in the Stage 12 tables.
A context that leaves the field out, as the Simulation Entrypoint's does,
keeps the Stage 12 behavior. A returned report cannot name a production
job to compare with, because that comparison is recorded on the Stage 12
report row.

Nothing reads the field yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 12 coordinator now leaves result_delivery out of the context it
sends to its baseline and reform workers, so their payload has exactly
the fields it had before the field existed. A coordinator whose report
returns to its caller will send the field, so its workers record nothing
in the Stage 12 tables either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New policyengine_simulation_contract.report_execution:

- ReportExecutionResult: the report's aggregate inline as a
  SingleYearMacroOutput, its SHA-256 (report_aggregate_sha256, over
  compact key-sorted JSON, rejecting NaN and infinity), the output plan,
  the baseline and reform artifact descriptors, and provenance (bundle,
  worker version, Modal environment, application, simulation function,
  version manifest digest and coordinator call ID). It rejects artifacts
  that do not form the report, a digest that does not match, and
  requested aggregates that differ from the ones the output plan was
  built for, so the two cannot disagree undetected.
- ReportExecutionFailure and ReportExecutionError: the redacted error
  code and summary a Stage 12 row would record, under a fixed message.

A test sends the error through Modal's own exception transport
(pickle_exception in the container, _process_result in the caller) and
gets the same type with the same failure back.

Nothing produces these types yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation worker given a context whose result_delivery is
return_to_caller reads and writes no Stage 12 rows and never builds the
database store. It checks the policy periods, writes the input and the
Parquet artifact exactly as a Stage 12 worker does, and returns the
artifact descriptor. A failure is reported once, recorded as the
operation's cause, and raised as ReportExecutionError with the redacted
code and summary a Stage 12 row would record, including a segment's
failure and typed input failures such as invalid_policy_period.

No record can tell a returned simulation to stop, so a segmented US run
is never told to stop through the database; its coordinator cancels it
through Modal instead.

The Stage 12 worker is unchanged: the policy check, input write,
calculation and artifact write move into _SimulationRun, which both
modes use, and the failure detail into _simulation_failure_detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New stage12_runtime.coordinate_report_for_caller runs a report whose
context asks for return_to_caller and returns a ReportExecutionResult
payload with the aggregate inline. It reads and writes no Stage 12 rows
and never uses the database store:

- It claims nothing, so a repeated dispatch runs the report again; the
  caller deduplicates.
- It writes artifacts under the Stage 12 layout built from the context
  (environment, creation time, report), replacing any prefix the caller
  sent, and starts both workers with the same delivery.
- It plans the output, starts both workers, waits for them within the
  shared window, and reads and checks their artifacts with the same code
  the Stage 12 coordinator runs. It then aggregates, applies the checks a
  Stage 12 aggregate artifact write applies, requires the aggregate to
  describe this report, and validates its result as SingleYearMacroOutput.
  No aggregate artifact is written and no production comparison runs.
- A failure is reported once, recorded as the operation's cause, and
  raised as ReportExecutionError with the redacted code and summary a
  Stage 12 row would record. A worker's own ReportExecutionError takes
  precedence over its call error, as a Stage 12 worker's recorded
  failure does. A worker that must stop is cancelled through Modal, and a
  cancellation error is logged without replacing the failure.

The shared steps move unchanged into ReportSimulationCoordination, whose
subclasses decide what a failed or stopped child records. The Stage 12
coordinator keeps its behavior, and now refuses a context that asks for
return_to_caller before claiming anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v2 Modal application's coordinate_report function now takes its
parent record as an optional argument. A context whose result_delivery
is return_to_caller runs coordinate_report_for_caller, refuses a parent
record, and labels its operation with result_delivery=return_to_caller
so telemetry tells it apart from Stage 12 runs. Every other context runs
the Stage 12 coordinator exactly as before, with the same operation
attributes; the Simulation Entrypoint still passes all three arguments,
and a Stage 12 report without a parent record is still rejected before
anything is claimed or started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal stops a cancelled synchronous input by raising InputCancellation in
it. That is a BaseException, so the segment runner's except Exception
handlers never ran, and the up to 20 region-group segment calls the worker
had started kept running until they finished or reached their timeout.

The segment runner now cancels every started segment call when dispatch
or the wait stops on a BaseException that is not an Exception, then
re-raises it. Failures that are Exceptions keep their existing handling,
so a segment failure or a cancellation read from the Stage 12 record still
cancels each segment exactly once.

A returned report's coordinator stops its workers through Modal, so this
is how a returned segmented run stops its segments. On the Stage 12 path
the coordinator cancels a segmented worker through Modal only when it
could not record the cancellation; that fallback now stops the segment
calls too. Stage 12 records and results are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal stops a cancelled coordinator input by raising InputCancellation, a
BaseException, in it, and does not cancel the calls that input spawned.
The returned coordinator caught only Exceptions, so its baseline and
reform workers kept running after a caller cancelled the report.

coordinate_report_for_caller now cancels every started, unfinished worker
through Modal when it stops on a BaseException that is not an Exception,
then re-raises it. A cancellation is not a report failure, so nothing is
reported or raised as ReportExecutionError. With the segment runner
change, a cancelled segmented worker then cancels its segment calls.

The Stage 12 coordinator is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that sends ReportExecutionError through Modal's exception
transport imports two private Modal client functions. It moves into its
own module, so a Modal upgrade that moves them fails only that module
instead of every report execution test.

A new test checks that the Modal coordinate_report function raises a
returned report's ReportExecutionError unchanged, as the same object, and
still shuts its telemetry runtime down. Modal pickles exactly what the
function raises, so with the transport test this covers both halves of
the error's path to the caller; neither runs a real Modal container.
Both tests cover existing behavior and passed on their first run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ings

The module docstring described Stage 12 coordination only, and
ReportSimulationCoordination._failure_detail still described Stage 12
steps although the returned coordinator shares it. Both now say what each
coordinator records. Documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two short windows remained in which Modal's InputCancellation left
segment calls running: while a dispatch span was closing after its call
had started, because the call was added to the list only after the span
closed; and between the end of the dispatch phase and the start of the
wait.

The segment runner now adds each call to its list inside the dispatch
span, and one handler covering both the dispatch and the wait cancels
every started call on a BaseException that is not an Exception. Failures
that are Exceptions keep their existing handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two comments still said that cancelling a segmented worker through Modal
leaves its region-group segment calls running. Since the segment runner
answers Modal's InputCancellation by cancelling them, both now describe
that. The Stage 12 coordinator still writes the cancellation to the
worker's record first and uses Modal only when that write fails.
Documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The returned coordinator rejects an aggregate whose report ID, bundle or
requested aggregates differ from the report, but only the report ID was
tested. The test now covers all three. It covers existing behavior and
passed on its first run; with the bundle and requested-aggregates
comparisons removed from the coordinator, those two cases fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReportSimulationCoordination._spawn_child returned a worker's call only
after the dispatch span had closed, and its callers stored it after
that. If Modal cancelled the returned coordinator in that short window,
the coordinator could not see the call, and the started worker (with a
segmented US worker's 20 segment calls) ran to the end for a report no
caller would receive.

_spawn_child now keeps the call in calls inside the dispatch span, and
both coordinators rely on it instead of storing the call afterwards. On
the Stage 12 path this also means that a failure while the span closes
stops the worker that had just started, instead of leaving it running;
Stage 12 records are otherwise unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator operation of a returned report carries
result_delivery=return_to_caller, but its baseline and reform workers'
operations did not, so their timings and failures were counted as Stage
12 worker data unless a query joined them through the trace. The US and
UK worker functions now add the same attribute when their context asks
for the report to return to its caller. Stage 12 workers keep exactly
their current attributes (tested). The region-group segment function
receives no dispatch context and is unchanged; its spans join the
worker's trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The returned result crosses Modal as JSON values so that the container's
and the caller's pydantic versions need not agree, but
ReportExecutionError pickled its ReportExecutionFailure model itself,
with pydantic's internal state. It now pickles the failure's JSON-mode
values and rebuilds the model when it is unpickled. Modal's exception
transport still raises it in the caller as the same type with the same
failure (tested), including from Modal 1.4.2 in a worker to Modal 1.5.5
in the Simulation Entrypoint's environment (checked once by hand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tore it

When Modal pre-empts a call, it restarts it on another machine and the
restarted call starts over. A returned report's worker wrote its Parquet
output to a fixed key, and the bucket accepts a second write to a key only
when the bytes are identical. A restarted calculation can differ from the
first attempt's in its last bits, so the restarted worker failed with
"immutable Stage 12 artifact path contains other data", and so did the
workers a restarted coordinator starts, turning a pre-emption into a
failed report.

A returned simulation now stores its output under
simulations/<role>-<sha256>.parquet (write_simulation_named_by_digest): an
identical output reuses the earlier object, and a different one is stored
beside it. Its input JSON keeps its key, because a restart plans the same
input and writes the same bytes. The comparison path keeps its fixed keys.

Tests: a restarted worker whose output differs by one bit stores it, one
with the same output reuses the artifact, and a returned report whose
coordinator is interrupted after the baseline stored its output finishes
when restarted on another machine. The first and third failed with the
collision before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restarted call can land on a redeployed copy of the same Modal
application, whose output planning may differ, and a repeated dispatch can
reuse a report ID with a different planned input. Either way the returned
worker wrote its input JSON to the same fixed key with other bytes and
failed with "immutable Stage 12 artifact path contains other data".

A returned simulation now stores its planned input under
inputs/<role>-<sha256>.json (write_input_named_by_digest), as it already
stores its output. The comparison path keeps its fixed keys; a new test
runs a comparison-path worker over the real artifact store and checks
both keys, since the test fake gives both kinds of write one key per role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add libs/policyengine-simulation-persistence, the package that will hold the
Simulation Entrypoint's own Postgres schema, "simulation". This commit holds
only the names and the connection:

- the schema name, the runtime role name, the naming convention and the
  metadata every table of the schema registers in;
- the runtime engine, built from the canonical postgresql:// URL the
  deployment stores, with the psycopg 3 driver, a small pool, pre-ping, a
  connect timeout and no server-side prepared statements (so the same URL
  works through Supabase's poolers);
- a reader for the applied Alembic revision that needs no Alembic.

A malformed URL raises ValueError without repeating the URL or the parser's
message, since the URL holds a password.

The package is unit-tested in the PR test matrix and by make test,
type-checked by the Pyright job under sim.quality.pyright-libraries-and-entry,
and scanned by the database access guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace sim.stage12.no-migration-chain, which forbade any Alembic chain in
this repository, with ownership rules for the Simulation Entrypoint's own
"simulation" schema:

- sim.persistence.one-migration-chain: one Alembic chain at migrations/,
  configured by migrations/alembic.ini, keeps its version table at
  simulation.alembic_version, and no other Alembic configuration,
  environment or revision directory exists;
- sim.persistence.executor-independent: the Modal executor and gateway never
  declare, lock or import the persistence library;
- sim.persistence.no-modal-database-credentials: no Modal secret or
  deployment receives SIMULATION_DATABASE_URL or
  SIMULATION_MIGRATION_DATABASE_URL, so the comparison path's own database
  secret stays the only database credential Modal holds;
- sim.persistence.migration-cycle: on a fresh PostgreSQL, the chain upgrades,
  passes alembic check, downgrades and upgrades again as a non-superuser
  migrator, and the runtime role reads the revision but cannot change the
  schema.

The environment creates the schema only when it is missing, because
PostgreSQL checks CREATE on the database even for CREATE SCHEMA IF NOT
EXISTS; an operator may therefore create the schema in advance, owned by the
migrator. The schema creation and every pending revision run in one
transaction, so a failed migration leaves nothing applied. Revision 0001
grants the runtime role schema usage, revision reads and default DML
privileges on the tables later revisions create. Migrations run only online
and only with SIMULATION_MIGRATION_DATABASE_URL.

A new "Simulation database" PR job runs the database tests on postgres:17
and is part of "CI complete". The database access guard now also scans the
chain, which may import alembic and change the schema but still connects
only through SQLAlchemy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check

The Simulation Entrypoint reads SIMULATION_DATABASE_URL, the runtime role's
URL for its own schema. When it is set, the app builds a pooled engine (no
connection opens until first use), disposes of it at shutdown, and GET /ready
reports the database's applied migration revision next to HEAD_REVISION, the
revision the image expects:

  {"status": "ready",
   "simulation_database": {"reachable": true, "revision": "0001",
                           "expected_revision": "0001"}}

The database does not change the readiness status, because no old-gateway or
Stage 12 route uses it yet: an unreachable or slow database (bounded at three
seconds) is reported as unreachable, logged with its error type only, and
/ready keeps following the old gateway. Without the setting, /ready keeps its
old shape. A URL that does not use the canonical postgresql:// scheme fails
validation without repeating the URL.

The entrypoint depends on the persistence library, so its image's build
context now includes it; a new test checks that every local library the
entrypoint installs is in the build context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each deployment (the main deploy and the Stage 12 deploy both use the
reusable workflow) now runs a migrate_simulation_database job after prepare.
It installs the persistence library's locked environment with the migrations
extra and runs .github/scripts/migrate-simulation-database.sh, which applies
the chain with alembic upgrade head and then requires the database to be at
the head. The job is the only one that receives
SIMULATION_MIGRATION_DATABASE_URL (a GitHub environment secret in beta and
prod), in its migration step's env alone. deploy_entrypoint needs the job and
runs only when it succeeded.

The deploy script now requires SIMULATION_DATABASE_URL_SECRET_NAME (a GitHub
environment variable in beta and prod) and mounts that Secret Manager secret's
newest enabled version as SIMULATION_DATABASE_URL on every deploy, whatever
the Stage 12 setting. The candidate smoke check sets
REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION=true and then requires /ready to
report the database reachable and at the image's revision; the stable and
rollback checks do not, because an older image may face a newer schema.

The wiring is registered as sim.persistence.migrate-before-entrypoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deploy script built its Cloud Run secret list in array assignments with
one command substitution per secret. Bash gives such an assignment the exit
status of its last substitution only, so with SIMULATION_DATABASE_URL added
last, a failure to resolve the old gateway's client secret (an invalid name,
a failed version listing, or no enabled version) no longer stopped the
script, and it deployed with an empty secret reference. The Stage 12 block
already hid failures for the two Modal token secrets the same way.

Each secret is now resolved in its own assignment, through
add_runtime_secret, so set -e stops the script at the first failure, before
gcloud run deploy runs. A test covers every runtime secret; three of them
deployed before this change. The test is registered under
sim.deploy.workflow-shell-safety.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sim.persistence.no-modal-database-credentials checked only the two
simulation URL names, and only in a Modal step's own env and run text. A
step could still give Modal any other database URL, such as one named
REPORTS_DATABASE_URL or a written-out postgresql:// URL, and a value set in
the job's or the workflow's env reached the step without being checked.

The check now finds any variable named like *DATABASE_URL* and any
written-out PostgreSQL URL in the workflow, job and step environments of a
step that creates a Modal secret or deploys a Modal application, in the
scripts such a step runs, and in the executor's and gateway's sources. Only
the comparison path's STAGE12_DATABASE_URL, and the name of the secret that
holds it, may reach Modal until the comparison path is removed. The rule's
statement and the library README say so, and the new tests are registered
under the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Modal credential check required at least one character before
DATABASE_URL, so a Modal step or a Modal application source using a
variable named exactly DATABASE_URL passed it, although the rule allows no
database URL in Modal except the comparison path's. The pattern now matches
the bare name, and the tests cover it in a workflow step and in a source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e head

The Stage 12 deploy workflow can be dispatched from any branch, and it uses
the same reusable workflow, so it would have applied that branch's
unmerged revisions to the beta or prod database; every later deploy from
main would then fail, because the applied revision is not in main's chain.

The migration step now sets SIMULATION_MIGRATION_MODE from the ref:
upgrade on main (alembic upgrade head, then current --check-heads), verify
elsewhere (only current --check-heads). The script refuses a missing or
unknown mode. Its unused SIMULATION_ALEMBIC_CONFIG override is gone.

alembic current --check-heads first appeared in Alembic 1.17.1, so the
persistence library's floor rises from 1.16 to 1.17.1 (the lock already
pins 1.20.0). A new PostgreSQL test runs the script itself against a fresh
database: verify refuses it, upgrade migrates it to the head, and verify
then passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 23 commits October 28, 2026 02:03
Merges v2plan/1j-139-staging-promotion (#894) at 4f34bf0. The staging
economy check runs after the staging deployment promotes the revision it
tested, which #894 adds.

Main has since split tests/test_deployment_assets.py, so #894's test
changes move into main's structure: its path constants go into
test_deployment_assets_support.py, and its staging promotion check and
rule proof stay in test_deployment_assets.py, which the rule registry
names, using the support module's workflow_jobs. The changelog keeps both
sides' entries, and the generated rules document and AGENTS.md are
rendered again from the merged registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The economy check waits for the staging promotion; a test now requires
both workflows that deploy staging to ask for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog publishes some regions, such as US places, without a filter
that selects their households. Such a region would have calculated the
whole country's dataset and stored it as the region's result. The catalog
reader now refuses a region that is not national and has no filter, and
the call's population refuses one too. The workers look a catalog dataset
up by its name alone, so a comparison no longer builds a population.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A labour supply report forces new runs and asks them for their labour
supply columns. When its submitter stops before starting one, another
report can reuse the pending run and start it; it now asks for those
columns whenever any report run comparing the run needs them, as the
safety check already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog population module now returns its own region and dataset
values; the Stage 12 calculation, which already reads the main path's
region and selection types, wraps them where it uses them. No behaviour
changes. The dispatch module's docstring names the routes and the safety
check that now start calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… own module

`main` allows a source file at most 800 lines, and the economy service
grows past that with the fixes that follow. Its requests, errors and store
protocols, and the catalog population a call carries, move unchanged to
`economy_report_types`; `economy_reports` keeps the service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each safety-check pass now starts the waiting work item by item: a
failure is logged and leaves that item for the next pass instead of
stopping the others. It looks only at economy runs, so household runs are
left to their own path. A pending run that no report run compares, or that
its catalog version can no longer resolve, is failed, so it is not picked
again. The pass counts only the runs and comparisons whose calls started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The default pass now builds from the environment main receives, as the
database URL already did, instead of reading the process environment
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The economy report routes read only economy reports; a household report
has no comparison step, so it is not described there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A labour supply report creates its forced runs and records its report run
in separate steps. A report reusing such a run in between could start it
without the labour supply columns. A submission now starts only the runs
it created. A reused run still pending is started by the submission that
created it or, if that submitter stopped, by the safety check, which asks
for the columns any report run comparing the run needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog population a simulation call carries has its own checks (a
state code of two capital letters, state filters keyed by state, list
values of text). A submission now builds it while checking the request,
so such rows are refused with 422 before any write, and the safety check
fails a waiting run whose rows cannot be sent instead of retrying it.
Running a household report on the economy path answers as for any other
report that is not an economy report: not found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API stores each period date as midnight UTC. Formatted in a
session whose time zone is behind UTC, they moved back a day; they are
now converted to UTC first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging check ran its report again with fresh runs every time, so on
a new catalog version both the submission and the re-run calculated it.
It now runs it again only when the submission reused runs an earlier
deployment finished, so each deployment costs one simulation call and one
comparison call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API stores `regions.region_type` as the PostgreSQL enum
`v2_region_type`. PostgreSQL has no `=` between that enum and a text value,
so the state filters of a US national run and the UK area names a stored
result is read with failed with "operator does not exist: v2_region_type =
character varying". The catalog reads now compare the column cast to text,
so they need neither the enum's values nor a change when the Public API adds
one.

The test copies of the Public API tables now use the real types: the enum
for `region_type` (NOT NULL, as is `label`) and `json`, not `jsonb`, for
`value_json`, and a test checks both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forced start (a labour supply report's, or a forced re-run) that read the
same next attempt as an unforced caller, and lost the insert to it, returned
the other caller's run as not created. The forced caller then did not start
it, and the run started without the labour supply columns its report needs,
so that report run failed. A forced start now adds the attempt after the one
it lost; an unforced start still returns the winner's run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocument

The routes answer sim's own refusals as 422 with a text `detail`, and an
unknown record or an unavailable database as 404 or 503, but the OpenAPI
document declared only FastAPI's validation 422, whose `detail` is a list.
The generated client therefore raised a parse error on sim's own 422s and
returned nothing for 404 and 503.

Each route now declares its error answers: 404 and 503 as
`EconomyErrorResponse` (`detail` as text), and 422 as
`EconomyRefusalResponse`, whose `detail` is either sim's reason as text or
FastAPI's list of refused fields. A test checks the declarations and that
each answer's body matches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report read whose succeeded run's stored result could not be rebuilt, for
example because it names a UK area the catalog version lacks, raised a
LookupError that no route mapped, so it answered FastAPI's plain 500. The
report reads now answer 500 with an `EconomyErrorResponse` body, declared
in the OpenAPI document, and log the report run and the error type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report migrated from v1 without a run is calculated when it is first
opened, through a `system` run. Two first opens at the same time each
recorded a report run, so the comparison ran twice, and a labour supply
report calculated its simulations twice.

A `system` run now returns the report's newest run when it has one. Report
runs recorded for a `system` run go through `RunStore.create_first_report_run`,
which takes a transaction-level advisory lock on the report, so simultaneous
first opens record one report run and the others return it. A first open
that loses still starts the runs it created that the recorded run compares,
and fails the others (a labour supply report's own forced runs) instead of
leaving them for the safety check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging check's docstring said it always costs one simulation call and
one comparison call; when the submission reused a finished run, the
submission compares that run at once and the check's fresh run adds a
second comparison call. The deployment guide now says that a report reusing
a run another submission created waits for that submission to start it, up
to ten minutes plus one safety-check interval if it stopped first, and
describes the system-run reuse and the declared error answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When two first opens of a labour supply report race, each forces new
simulation runs, and the one that loses the report run did not need its
own. It failed them at once as abandoned. But in the moment between
creating them and losing, another report sharing a simulation, usually the
current-law baseline, could reuse one of them and wait for it to start; that
report then failed for a reason that was false for it.

The losing caller now leaves those runs pending. The safety check starts a
pending run for every report run comparing it, or fails it when nothing
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt limit

The persistence library's readme said concurrent starts always settle on
one row; a forced start now adds the attempt after one it lost, and gives up
after 10 lost inserts in a row. It also describes
RunStore.create_first_report_run and its transaction-level advisory lock. A
test covers the forced start that loses every insert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The persistence tests create copies of the Public API tables sim reads and
refers to, with only the columns sim uses. A copy that differs from the
real table hides bugs, as a varchar copy of the enum column
regions.region_type did. test_public_api_schema now compares, for every
table the copies create, each column's type (an enum by name, and its
values), whether it allows null, and each primary key and unique
constraint by name and columns, with a database the Public API's own v2
migration chain built (PUBLIC_API_SCHEMA_DATABASE_URL). A column or table
added to the copies is compared with no other change. Without that
database the comparison skips, unless SIMULATION_REQUIRE_PUBLIC_API_SCHEMA
is 1.

Against the Public API's default branch it found the copies differed:
parameter_values.value_json is jsonb there, not json; model_id and version
of tax_benefit_model_versions, and tax_benefit_model_version_id of
policies, refuse null; and the primary keys and the model name's unique
constraint carry the Public API's names. The copies now match, and the
test rows fill the columns that refuse null. A proof test checks the
comparison on fixtures: the copies with regions.region_type as
varchar(64) are refused, and the current copies are accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new pr.yml job, Public API schema, checks out PolicyEngine/policyengine-api
(public, so without a secret or persisted credentials) at its default
branch, builds its tables with its own v2 migration chain into the job's
PostgreSQL (.github/scripts/public-api-schema-reference.sh, which prints
the commit it checked), and runs the persistence tests marked
public_api_schema, which fail rather than skip there. CI complete needs
it. Following the default branch, rather than a pinned commit, makes a
change there to a column sim reads fail sim's next CI run instead of
waiting for someone to move a pin.

The new rule sim.persistence.public-api-copies-match records this, with
its proof, and the persistence readme describes the copies, the job and
how to run it locally. The job reads no secret: its database URLs and
switches are literals, and the script sets the Public API's migration
settings from its input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 2 commits October 28, 2026 16:56
Enum columns are described by type name without their schema, so an enum
the Public API moved out of its public schema would have passed with its
values unchecked; the comparison now reports it. A test covers each kind
of difference the comparison reports: a column's nullability, a missing
column, table or constraint, a constraint with its columns in another
order, an enum's values, and a missing enum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reference script ran `git rev-parse` inside `echo`'s arguments, so a
failure printed an empty commit and went on. It now stops, and on GitHub
Actions passes the commit to the later steps as PUBLIC_API_SCHEMA_COMMIT,
which a failed comparison names. The persistence readme lists the local
steps, including checking out the commit a failed run names.

A test now checks that a json copy of parameter_values.value_json, which
the Public API's baseline revision created as json and a later revision
made jsonb, is refused. The hand-written check of two column types in
test_catalog_reads, which the comparison now covers, is removed, as is
the unused JSON writer, and the copies' docstring says they also hold the
columns the catalog reads select.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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