Skip to content

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

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

anth-volk wants to merge 61 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>
Merges v2plan/1c-109-typed-coordinator-result (#896) at 52f9be4 into the
rebased result-tables branch, which is on main. Main has since split the
modules #896 changes, so its changes move into main's structure:

- the contract's ReportResultDelivery and the invocation context's
  result_delivery field go into stage12_execution/comparison_runs.py, and
  the package exports ReportResultDelivery;
- ReportSimulationCoordination, the Stage 12 ReportCoordination subclass
  and _child_context_payload go into report_coordination.py; the child
  call cancellation and the child wait failure report go into
  coordinated_simulations.py as cancel_child_call and
  report_child_wait_failure, public because report_coordination uses them;
  coordinate_report keeps the refusal of a returned report, and
  coordination.py re-exports ReportSimulationCoordination and
  validate_dispatch_context for returned_coordination;
- the tests use main's support modules: the digest-named writes are on
  FakeArtifacts, ContextRecordingInvoker and ReformDispatchFailureInvoker
  move to stage12_child_call_support, the context test joins
  test_stage12_coordinator.py, and the returned report and artifact tests
  import main's helper names.

#896 makes stage12_runtime/simulation.py 830 lines, over main's limit, so
its record handling (descriptor_from_record, the claim, the cancellation
check and the recorded success and failure) moves unchanged to
simulation_records.py; simulation.py re-exports descriptor_from_record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An economy simulation run's call names the run, its policy's parameter
values, and the region and dataset its catalog version has: the dataset's
name and the household column and value that select the region, with each
state's filter for a US national run. The call returns the files it stored.
A report run's comparison call names both runs' stored outputs and policies.
A policy that changes a labour supply elasticity is recognised by the
parameter paths policyengine.py itself checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation that carries a catalog population in its options loads the
dataset the catalog names and keeps the households its catalog filter
selects: a stored value written as an integer, such as a state's FIPS
number, is compared as one, and a list 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. The installed bundle check
requires the provenance of the catalog's dataset. Stage 12 runs carry no
catalog population and resolve their regions as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The call checks that it runs on the PolicyEngine.py version and release its
catalog version names, plans the columns any report comparing the run reads,
and calculates it as a Stage 12 worker returning its result: no database
rows, the catalog's dataset and filter, segments for a US national run, and
input and output stored under keys naming their SHA-256. The labour supply
response columns are planned only when the call asks for them, since in the
UK they mean simulating again. A simulation run has no report role, so its
Stage 12 input names it the baseline. The call returns the stored files'
locations and digests, the output plan's digest and the bundle provenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comparison reads both outputs, refuses one whose bytes differ from the
digest recorded for it, requires both to hold the same households and the
columns the pair's comparison reads, and aggregates them with the Stage 12
aggregation into a SingleYearMacroOutput. A run compared with itself is
read for both sides. The call writes nothing, so a restarted call runs
again. A failure reaches the caller as the typed, redacted error a
returned Stage 12 report raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_economy_simulation_us and _uk calculate one simulation run on their
country's worker image, with the Stage 12 workers' time limits, the US one
in segments of this application; compare_economy_simulations compares a
report run's two outputs on the coordinator image. Each starts
report_call_ended with its call ID and run ID when it returns or raises.
A pre-empted or cancelled call sends no report: its segment calls are
cancelled, and Modal runs a pre-empted input again under the same call ID,
which reports when it ends. 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runtime role reads, from the Public API's tables in the same database,
a country's catalog version for a PolicyEngine.py version; a region and its
default or named dataset in that version, with the household column and
value that select it, and each state's filter for a US national run; a
policy's and dynamic's parameter values by name and period, refusing the
run when its catalog version lacks a parameter they change; and the UK
area names a stored result is read back with. A region selected by
anything but a row filter cannot run. The tests grant the runtime role
SELECT on these tables, as the operator will.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation with an existing definition is found rather than created
again, so its runs can be reused. A report's runs are listed newest first.
A comparison step is ready to start once both its runs succeeded, also
when one run is compared with itself; the runs a finished call computed
narrow the search. Runs left pending, whose submitter stopped before
starting their call, are found after a while. A run's stored files are
read by role while they are offered. A run its catalog version cannot run
fails as invalid_input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 26 commits October 28, 2026 01:56
After recording due calls, each safety-check pass starts the comparison
steps whose two runs have succeeded, which a lost call report or a stopped
instance left pending, and starts the runs a submitter left pending for
ten minutes, asking for labour supply columns when any report run
comparing the run needs them. The safety-check job now also receives the
serving revision's Modal environment, where it starts these calls. Each
pass logs how many comparisons and runs it started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the staging deployment has promoted the revision it tested, a new job
submits a report on Wales, current law compared with itself, to the
stable URL, runs it again with fresh runs so this deployment's Modal
functions calculate it, waits up to twenty minutes for its comparison,
and reads the result back. It uses the test client the deployed-auth
checks use. The v2 economy path serves no user yet, so the job's failure
shows in the run without holding back the production deployment. The test
lives with the deployed-auth checks and is skipped wherever the job does
not ask for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deployment guide lists the economy report routes, the Modal functions
and environment they start calls in, the Public API tables the runtime
role must be granted SELECT on, the safety check's waiting work, and the
staging check after each promotion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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