Skip to content

Record report runs and simulation runs from the v2 Modal coordinator and workers - #992

Draft
anth-volk wants to merge 23 commits into
mainfrom
v2plan/b3-3-workers-write-runs
Draft

anth-volk wants to merge 23 commits into
mainfrom
v2plan/b3-3-workers-write-runs

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #991

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

The v2 Modal report coordinator and its US and UK simulation workers can now record a report's run in the Public API's run tables, with the Modal worker role's own database credentials. When a call's invocation context names a report run and its baseline and reform simulation runs, the coordinator and workers write those runs' status, each simulation's output file and the report's economy results. The report run is marked succeeded in the same transaction as its result rows, so a call that Modal restarts after pre-emption cannot record its result twice. A call that names no report run behaves exactly as today. Nothing sends such a call yet: the simulation service's /simulations and /reports routes, a later change, will.

How a call names its runs (policyengine_simulation_contract)

Stage12InvocationContext.report_run (new, optional): a ReportRunIds with report_run_id, baseline_simulation_run_id and reform_simulation_run_id, three different IDs. A shadow run compared with a production call cannot carry it. Comparison runs leave it unset, and their child calls receive exactly the context fields they received before.

Coexisting with the comparison path

The smallest change: a call that names a report run still runs through the Stage 12 comparison records (stage12_evaluation_reports, stage12_evaluation_simulations) and the Stage 12 result bucket, which coordinate its workers as before, and also records the Public API's runs. Those writes go through a second store (ReportRunStore, in policyengine_stage12_persistence), connected as the Modal worker role.

  • Coordinator: checks with one SELECT that the report run combines the call's two simulation runs, then claims the report run under its own Modal call ID, then its Stage 12 report. A report run that another call holds, or that is already finished, makes the call return a deduplicated summary and start nothing. It marks its Stage 12 report succeeded or failed first and its report run second; the report run's success is written in one transaction with its result rows. Any failure, including one claiming the Stage 12 report, fails the report run with a redacted code and summary.
  • Workers: a wrapper around the comparison store (SimulationRunRecordingStore) writes each Stage 12 child record first and then mirrors its claim, success and failure onto that child's simulation run: the worker claims its simulation run under its own Modal call ID, records the output file as the run succeeds, and a recorded failure, including the coordinator's cancellation of a child, fails the simulation run. A simulation run that is already failed refuses the worker's claim, which it treats as a cancellation.
  • Lost second writes: before it completes or fails a report run, the coordinator gives each of its two simulation runs its child record's outcome, with the same conditional writes, so a write a worker lost (a dropped connection, or a worker stopped between its two writes) is made then, and a report run never succeeds beside a simulation run still running. A worker whose second write fails after its child record succeeded logs it and returns its result.
  • Every write is conditional: each status write is one UPDATE … WHERE id = … AND status = … (and, for the report run, AND modal_call_id = …), returning whether it changed the run and the run's status, so a repeated write changes nothing that is final.

Restarts and cancellation

Modal pre-empts a call with SIGINT, received as KeyboardInterrupt, and runs it again under the same call ID; it cancels a call with InputCancellation, and never runs a cancelled call again. Neither is an Exception.

  • Pre-emption: a coordinator whose call names a report run cancels its started, unfinished child calls and re-raises, recording no failure. When Modal restarts it, it resumes the Stage 12 claim it holds under that call ID (instead of returning a duplicate), cancels the child calls its earlier attempt started, and starts the children again.
  • A restart between the two success writes: the Stage 12 record is written first, so a restarted call finds it finished. A coordinator whose Stage 12 report succeeded under the same call ID reads the stored aggregate, checks its digest, and completes the report run from it without calculating again. A worker whose child record succeeded completes its simulation run from that record, so both name the same output file. A restart after both writes starts nothing.
  • Cancellation: a coordinator whose call is cancelled records its unfinished children, its Stage 12 report and its report run as failed (report_cancelled), because nothing would finish them otherwise; so does one cancelled while it claims its Stage 12 report.
  • Segments: a segmented US worker that Modal stops now cancels its region-group segment calls, on every path; Modal does not cancel the calls a stopped call spawned. This also applies to comparison runs and changes none of their records.
  • Artifacts: such calls write their artifacts under keys that name the content's SHA-256 (inputs/<role>-<sha256>.json, simulations/<role>-<sha256>.parquet, reports/aggregate-<sha256>.json), so a restarted call that calculates different last bits stores them beside its earlier attempt's instead of failing the write-once check. Comparison runs keep their fixed keys.
  • Time limits are unchanged.

Results as rows

The report's result is the single-year economy output Stage 12 already builds, in the format the Public API and v3 read today. Each part becomes rows, with the meaning .py's row models give the result tables' columns, in the same transaction as the report run's success:

  • budget_summary (entity household): household_tax, household_benefits and, for the US only, household_state_income_tax (change), household_net_income and household_weight (baseline total, the latter the household count); the budgetary impact is tax minus benefits, as the output computes it, and a missing state income tax row is 0.0, as the output reports for the UK.
  • program_statistics: the detailed budget, one row per program, with a null entity (the output names none) and the output's is_tax flag.
  • decile_impacts: the change in household_net_income by income decile, with a null decile_variable, and (UK) by wealth decile, with decile_variable household_wealth_decile, as .py names them; the relative change is .py's percentage, so the output's fraction is multiplied by 100 (a reader divides again, equal to within floating-point rounding).
  • intra_decile_impacts: income and (UK) wealth deciles 1 to 10. Row 0 is .py's population-wide share, which the output doesn't carry: its overall shares are the mean of the deciles, which a reader computes again exactly.
  • poverty, per simulation run: the poverty and deep poverty rates under .py's poverty types (spm, spm_deep; relative_bhc, absolute_bhc), for everyone and by .py's groups: filter_variable names the variable that selects a group (age, is_male, race) and filter_group the group (child, adult, senior; male, female; white, black, hispanic, other), both null for everyone. Race covers poverty only, and only in the US, as the output does. Today's output reports 0.0 for a rate .py didn't report and for every rate of a breakdown it failed to calculate, so a stored 0.0 may be such a rate. inequality, per simulation run, under .py's income variables. A simulation run that another report run already stored a row for keeps its row (ON CONFLICT DO NOTHING).
  • congressional_district_impacts, with the winner, loser and no-change shares, keyed by the Census state FIPS code and district number (0 for an at-large state), from .py's US state data.
  • constituency_impacts and local_authority_impacts, with their disclosure tier and interval bounds.
  • report_run_comparisons: one row for the report run and its two simulation runs, holding the labour supply response, cliff impact and SPM provenance whole, and the data version.

Nothing else in the output needs a column: a reader computes the budgetary impact and the overall intra-decile shares again from these rows, and model_version is the report run's country_package_version, which the simulation service sets when it creates the run.

Tests

The store's tests run against a disposable Postgres migrated with the Public API's v2 chain from PolicyEngine/policyengine-api#4162, with that change's declared privileges applied by its own reconcile script, as the Modal worker role. They skip when REPORT_RUN_TEST_DATABASE_URL and REPORT_RUN_TEST_SEED_DATABASE_URL are unset, as they are in CI. They cover the conditional claims, completions and failures; a result whose insert fails leaving the run running, with no comparison row; a repeated completion storing nothing; a shared simulation run keeping its rows; a round trip of each part of the output, including the UK wealth deciles and the poverty rates by group; and the role being refused an insert into report_runs. The runtime tests use in-memory stand-ins for Modal and both stores, including coordinators pre-empted at each point between their writes and restarted, cancelled coordinators, restarted workers, lost second writes, and a report run through the real worker code.

Environment variables

  • MODAL_WORKER_DATABASE_URL (new): read by the coordinator and the US and UK simulation workers, only for a call that names a report run. Provider: the Modal secret policyengine-modal-worker-database, which an operator creates in the staging and main Modal environments (below). Wiring: modal.Secret.from_name(..., required_keys=["MODAL_WORKER_DATABASE_URL"]) attached through secrets= to coordinate_report, run_single_simulation_us and run_single_simulation_uk in src/modal/v2_app.py. Not verified: the secret does not exist yet.
  • STAGE12_DATABASE_URL, STAGE12_ARTIFACT_BUCKET (unchanged): the Modal secret stage12-evaluation-runtime, synced from Secret Manager by each deploy's stage12-sync-modal-secrets.sh.
  • REPORT_RUN_TEST_DATABASE_URL, REPORT_RUN_TEST_SEED_DATABASE_URL: test-only, set by hand for a local database; nothing sets them in CI.

What this waits for before merging

  1. Record each run of a v2 simulation and report, and key results by run policyengine-api#4162 merged and deployed: the run tables, result columns and report_run_comparisons this writes, and the Modal worker role's declared privileges.
  2. The Modal worker role policyengine_v2_modal_worker created, with a password, in the staging and production v2 databases (an operator step), so the Public API's deploy grants it its privileges.
  3. The Modal secret policyengine-modal-worker-database with key MODAL_WORKER_DATABASE_URL in the staging and main Modal environments (an operator step): the URL of that role on that environment's v2 database. Merging deploys the v2 Modal apps with the secret attached, and modal deploy fails without it, so this comes before the merge.
  4. An approving review.

The Modal secret (operator steps, for each of staging and main)

  • Name policyengine-modal-worker-database, key MODAL_WORKER_DATABASE_URL and no other.

  • Value: a postgresql:// URL that logs in as policyengine_v2_modal_worker to database postgres of that environment's v2 database, in the same format as the existing STAGE12_DATABASE_URL: postgresql://policyengine_v2_modal_worker.<project-ref>:<password>@<pooler-host>:5432/postgres?sslmode=require. The scheme must be postgresql://; the code selects the driver.

  • Create it from projects/policyengine-simulation-executor, without echoing the value:

    umask 077
    secret_file="$(mktemp)"
    # Write {"MODAL_WORKER_DATABASE_URL": "<the URL>"} into "$secret_file" with an editor.
    uv run --frozen modal secret create policyengine-modal-worker-database --from-json "$secret_file" --env staging
    rm -f "$secret_file"
  • Check it without printing it: modal secret list --env staging lists the name; psql "<url>" -Atc 'select current_user' (from a file holding only the URL) prints the role; select count(*) from public.report_runs succeeds and insert into public.report_runs … is refused. The first deploy after merging must pass modal deploy, which checks the required key.

🤖 Generated with Claude Code

anth-volk and others added 21 commits November 3, 2026 23:59
…s on

A report call's invocation context can now carry the IDs of the Public
API's report run and its baseline and reform simulation runs. Comparison
runs leave the field unset, and a shadow run compared with a production
call cannot carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v2 Modal application now attaches the Modal secret
policyengine-modal-worker-database, which must hold
MODAL_WORKER_DATABASE_URL, to the report coordinator and the US and UK
simulation workers. Region-group segments and worker validation do not
receive it. An operator creates the secret before this deploys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal pre-empts a call with SIGINT, raised as KeyboardInterrupt, and
cancels one with InputCancellation. Both are BaseExceptions that are not
Exceptions, and Modal does not cancel the calls a stopped call spawned.
The segmented US worker caught only Exceptions, so its region-group
segment calls kept running after Modal stopped it. It now cancels every
started segment call, then re-raises. Each started call is also recorded
inside its dispatch span, so an interruption as the span closes still
finds it. No database row or stored file changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A content-addressed store writes a simulation's planned input, its
Parquet output and the report's aggregate under keys that end in the
content's SHA-256. A call that Modal restarts after pre-emption can
calculate different last bits, or plan its input on a redeployed copy
of the application; its artifacts are then stored beside the earlier
attempt's instead of failing the write-once check, and identical bytes
reuse the earlier object. Comparison runs keep their fixed keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 12 persistence library can now update the Public API's
report_runs and simulation_runs rows: claim a report run under a Modal
call (a pending one, or a running one no other call holds, which also
lets a call Modal restarts resume its claim), mark it succeeded or
failed only while that call holds it, claim a simulation run, record its
output file as it succeeds, and fail a pending or running one. Each
write is one conditional UPDATE and returns whether it changed the run
and the run's status, so a repeated write changes nothing that is final.
It also checks that a report run combines the simulation runs a call
names. The mappings declare only the columns these writes touch.

The tests run against a disposable Postgres migrated by the Public API's
v2 chain, as the Modal worker role, and skip when no test database is
configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a worker's invocation context names a report run, the worker keeps
its Stage 12 child record as before and also records its own simulation
run, as the Modal worker role: it claims the run under its Modal call ID
after claiming the child record, records the output file and marks the
run succeeded before marking the child record succeeded, and marks the
run failed after a recorded failure. A wrapper around the comparison
store makes these writes, so the coordinator's own failure and
cancellation records reach the simulation runs too. A simulation run
that is already failed refuses the claim, which the worker treats as a
cancellation. Success comes first so a worker that Modal restarts
between the two writes finds its child record still running and runs
again, and its second output never replaces the first. Such a worker
names its artifacts by their digest. The US and UK worker functions now
pass their Modal call ID; comparison runs record nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the coordinator's invocation context names a report run, it first
checks that the run combines the call's baseline and reform simulation
runs and claims it under its own Modal call ID. A run another call
holds, or one already finished, makes the call return a deduplicated
summary before it starts anything. The coordinator marks the report run
succeeded before it marks its Stage 12 report succeeded, and marks it
failed, with the redacted failure, whenever it records a Stage 12
failure. Its failure and cancellation records of child simulations also
reach their simulation runs, and it names its aggregate by its digest.
Comparison runs record nothing new, and their children receive exactly
the context fields they received before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal pre-empts a call with SIGINT, which the coordinator receives as
KeyboardInterrupt, and later runs the same call again under the same
call ID; it cancels a call with InputCancellation. Neither is an
Exception. A coordinator whose call names a report run now cancels its
started, unfinished child calls on either and re-raises, recording no
failure. When Modal restarts it, it resumes the Stage 12 claim it holds
under that call ID instead of treating the report as a duplicate,
cancels the child calls its earlier attempt started, and starts the
children again; the report run is claimed again by the same call and
marked succeeded once. A restart after the report run succeeded starts
nothing. Comparison runs keep their behaviour. Time limits are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report run store can now write a report's single-year economy
output in the same transaction that marks the report run succeeded,
only when that status write changes the run. Budget totals, income
deciles and intra-decile shares become rows keyed by the report run and
its two simulation runs; poverty rates and inequality become rows keyed
by each simulation run, under .py's poverty types and income variables
for the country; the labour supply response and cliff impact are stored
whole on the report run. A repeated completion stores nothing, a failed
insert leaves the run running, and a simulation run another report run
already stored a row for keeps that row.

The geographic impacts, the detailed budget, the UK's wealth deciles,
the SPM provenance and the data version have no lossless place in the
result tables yet and are not written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A coordinator whose call names a report run now passes the economy
output of its aggregate to the report run store, which writes it as
result rows in the transaction that marks the run succeeded. An output
the store cannot hold fails the report, as any other failure does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API's result tables now have a place for the parts of the
economy output that had none. A report run's result also writes the
detailed budget to program_statistics, one row per program with a null
entity and the output's is_tax flag; congressional district impacts with
their winner, loser and no-change shares, keyed by the Census state FIPS
code and district number, an at-large district being 0, from .py's US
state data; UK constituency and local authority impacts with their
disclosure tier and interval bounds; and the SPM provenance and data
version on the report run. A district in a state .py doesn't name is
refused. The UK's wealth deciles still have no place and aren't written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result tables follow .py's row models. decile_impacts.relative_change
holds .py's percentage, while the economy output holds a fraction, so
the writer now multiplies it by 100 and a reader divides it again, equal
to within floating-point rounding. intra_decile_impacts row 0 is .py's
share across the whole population, while the output's overall shares
are the mean of the decile shares; the writer no longer puts the mean in
row 0, and a reader computes it again from deciles 1 to 10, exactly as
the output does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator now marks its Stage 12 report succeeded or failed first,
and its report run second; each worker marks its Stage 12 child record
succeeded first, and its simulation run second. A call Modal restarts
between the two writes therefore finds the Stage 12 record finished. A
coordinator restarted after its Stage 12 report succeeded under the
same call ID reads the stored aggregate, checks its digest, and
completes the report run from it without calculating again; one
restarted after a failure runs the report again. A worker restarted
after its child record succeeded completes its simulation run from that
record, so both name the same output file. Before, a restart after the
report run succeeded left the Stage 12 report running for good. The
claims now live in their own module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once a coordinator holds its report run, an error claiming its Stage 12
report, or completing the run from a stored aggregate, now fails the
report run before the error propagates, instead of leaving it running.
Modal cancels a call with InputCancellation and never runs it again, so
a coordinator whose call is cancelled now records its unfinished
children, its Stage 12 report and its report run as failed, with the
code report_cancelled; pre-emption, KeyboardInterrupt, still records
nothing, because Modal restarts that call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The persistence library's README and the database-access skill now
describe the Public API's run tables that the Modal worker role writes,
the store's module docstring says that a completion also inserts its
result rows, and the secret's module says that the secret must exist in
a Modal environment before the v2 application deploys there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In .py's poverty rows, filter_variable names the model variable a group
is filtered on (age, sex or race), with its bounds and group label in
columns the table doesn't have, so a group's rate has no column that
keeps .py's meaning, and the three age groups would collide on the
table's unique key as age. The writer now stores only each simulation
run's poverty and deep poverty rate for everyone, with a null filter
variable, instead of putting group labels in filter_variable. A UK
report no longer writes a household_state_income_tax budget row: the
UK model has no such variable, and today's output reports 0.0, which a
reader gives again when the row is absent. The area mapping is typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker writes its Stage 12 child record first and its simulation run
second, so a lost second write left the simulation run behind: a
failure the worker or coordinator could not mirror stayed running, and
so did a success when the worker stopped between the writes and a later
call reused the child. Before it completes or fails a report run, the
coordinator now gives each of the report's two simulation runs its
child record's outcome, with the same conditional writes, so a report
run never succeeds beside a simulation run still running. A worker
whose second write fails after its child record succeeded logs it and
returns its result instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator claims its report run, then its Stage 12 report. A
cancellation between the two, or while it completes the run from a
stored aggregate on a restart, now fails the report run with the code
report_cancelled before it propagates; Modal never runs a cancelled call
again. A pre-emption there still records nothing, because Modal runs
the call again and it resumes its claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator test runs both children through run_single_simulation,
sharing the coordinator's Stage 12 store and report run store, and
checks that the report run and both simulation runs are recorded once,
each simulation run under its own worker's call and naming its child
record's output file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A coordinator restarted after a pre-emption starts its children again;
if it failed, or was cancelled, before it had, the children its earlier
attempt claimed stayed running under calls that attempt had cancelled.
A failed report run's coordinator now fails each child record of the
report still running, which also fails its simulation run. A cancellation
during the report run claim's commit, or while the coordinator records a
failure, now still fails the report run. A restarted worker whose
completion of its simulation run fails logs it and returns its result,
as a worker does after a fresh success, since the coordinator completes
the run from the child's record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 2 commits November 4, 2026 02:45
The Public API's run tables changed under this work
(PolicyEngine/policyengine-api#4162): the labour supply response, cliff
impact, SPM receipts and data version moved from `report_runs` to a
`report_run_comparisons` row per report run and compared pair, and the
result tables gained the keys that hold the parts left out before.

- A report run's completion inserts its `report_run_comparisons` row with
  its other result rows, in the same transaction as its success.
- UK wealth deciles are stored in `decile_impacts`, with `decile_variable`
  `household_wealth_decile`; income deciles leave it null.
- Poverty rates by age, gender and race are stored with .py's groups:
  `filter_variable` names the variable that selects a group (`age`,
  `is_male`, `race`) and `filter_group` the group; both stay null for
  everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each poverty rate's measure, selecting variable, group and values are now
a named tuple, with every group listed under the variable that selects it.
The rows' docstring says that today's output reports 0.0 for a rate .py
didn't report and for a breakdown it failed to calculate. The UK test also
checks the income deciles' relative change.

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.

Record report runs and simulation runs from the v2 Modal coordinator and workers

1 participant