Skip to content

Let Stage 12 report runs share simulation runs, each storing its own simulation's output - #1072

Draft
anth-volk wants to merge 51 commits into
v2plan/c1-23-local-area-report-profilefrom
v2plan/c1-26-shared-simulation-runs
Draft

anth-volk wants to merge 51 commits into
v2plan/c1-23-local-area-report-profilefrom
v2plan/c1-26-shared-simulation-runs

Conversation

@anth-volk

Copy link
Copy Markdown
Contributor

Fixes #1071

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.

Changelog

  • This PR adds a changelog fragment, changelog.d/c1-26-shared-simulation-runs.changed.md, whose type is breaking, added, changed, fixed or removed.

Summary

Stage 12's v2 functions can now share simulation runs between report runs, as the Public API's simulation service is to do for identical requests. This follows the migration's shared-run decisions: answer 4 (each run stores what its own simulation decides), answer 5 (the coordinator waits for a run another call holds), answer 8 (household reports are keyed too, so the household function gets the same treatment), interim A (report runs asking for cliffs are refused for now; cliffs come back in a later stage as a calculation of each simulation run), and answer 3 with interim B (forced re-runs, and for now re-runs without a key, calculate extra runs nobody joins; a test covers this, and it needs no code).

Stacked on #1068, the local-area report profile; its commits up to 45ffb33 are merged in.

Waiting instead of cancelling (stage12_runtime.shared_simulation_runs, report_run_coordination):

  • The coordinator starts a worker for every simulation run that is pending with no Modal call, whichever report run created it. The worker's claim lets only one call hold the run.
  • For a run another call holds, the coordinator probes that call without cancelling or releasing it. Once the call ends, it reads the run again:
    • a run that succeeded is read from its stored file and checked as a reused run is, dataset and region included;
    • a run that failed fails the report run with that run's recorded failure;
    • a run left pending with no call gets this coordinator's worker;
    • a run still unfinished under the call that ended is failed while it still records that call (fail_simulation_run_held_by, modal_call_ended), so every report run waiting for it stops.
  • A probe that raises reads the run first. It counts as the call ending only once Modal reports the call ended (modal_call_state), because Modal's client also raises for a lost connection while the call goes on.
  • A coordinator that fails, is cancelled or is pre-empted writes only its report run. It never cancels workers or fails simulation runs other report runs may share. Its own worker's run is failed only for that worker's own failure, while the worker's call holds it. release_simulation_run is removed.

The household function (household_report_recording):

  • It calculates the sides it can claim first.
  • It then waits for a side another call holds, with the same rules, for up to 780 seconds from its start (HOUSEHOLD_SIDE_WAIT_SECONDS, derived from its 900-second Modal timeout less two minutes to finish). The holding call is another household function, which goes on after recording the run, so the wait reads the run before each probe and takes a finished run at once. A side still held when the window closes fails the report run with household_simulation_run_unavailable, and the run is left to its call.
  • When it fails, it fails only runs it holds.

One simulation run on both sides:

  • The contract accepts a report run naming one simulation run as baseline and reform when both sides are the same simulation, policy included. That case covers the staging check of current law against current law.
  • The coordinator and the household function calculate or read that run once.
  • The result rows store its poverty and inequality rows once.

What a simulation run stores (output_planning.resolve_simulation_output_plan):

  • Each worker gets its own simulation's plan, the same whatever report names the run:
    • the labour-supply response columns only when its own policy turns a response on;
    • in the US, the two inputs .py's labour-supply output reads from either side;
    • never the cliff columns, so a US national run keeps its region-group split.
  • The report keeps its pair's plan for the aggregation. A side storing no responses the report reads is read with them as zeros.
  • A run that succeeded before this change is reused when its file holds what the report reads from that side, and refused with reused_simulation_output_incompatible otherwise.
  • A report run asking for cliffs fails before any worker starts, with cliff_impacts_unavailable, and touches none of its simulation runs.

Precomputed US baselines are planned the same way, so a report run's current-law US national baseline finds them. The first deployment computes the 60 baseline files again under their new cache identities. A comparison-path call (a shadow comparison with a production job, or the operator-only POST /internal/stage12/reports) still plans its baseline with its report's plan, so its US national baseline no longer reads the precomputed files and calculates all 20 region groups. This costs compute, not correctness.

Stuck-run sweep:

  • The 24-hour age limit now fails a simulation run that records a Modal call, whatever report runs name it. New report runs could otherwise keep waiting for a call Modal never ends.
  • A run no call has claimed, such as a budget window's later year, still waits for the report runs naming it.
  • The pass that judges runs older than 10 minutes is unchanged.

For the simulation service (C1 · 27): it must not create shared pending runs, that is, name a run another report run created and that hasn't succeeded, until this is deployed to the default worker version's Modal application. Until then, a report run naming a run another worker holds would cancel that worker, and a failing report run would fail runs others wait for. Joining a run that already succeeded is safe today.

Deployment and environment:

  • No change to workflows, images or Terraform, and no new environment variable or secret. v2_app.py changes only where the household functions' 900-second timeout comes from: it now imports HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS from stage12_timeouts, which it already imported from.
  • The coordinator and the household function now ask Modal for a call's state (modal_call_state) and restore calls by ID with the Modal client inside their containers, as the stuck-run sweep and the coordinator's restore already do. This is not yet verified in a deployed container.

Known limits:

  • A waiting coordinator or household function holds one of its containers (25 coordinators, 20 household functions) while it waits.
  • The sweep doesn't end waits: waiters fail a run whose holder died, once Modal reports that call ended.
  • A probe or state lookup that hangs keeps a coordinator past its wait window until Modal's client gives up or the 5,400-second limit ends the call.

Testing

  • Each change was tested first: a failing test was committed with "(failing)" in its subject, then the change.
  • Tests were run individually by node ID: the new and changed executor, contract and persistence tests. The persistence tests ran against a disposable PostgreSQL 17 database that the Public API's migrations created.
  • Ruff format and lint, the repository rules, the quality checks, the lock checks and Pyright passed before each commit.
  • Four read-only review rounds, the last on the fixes after the third; their bugs were fixed test-first.

🤖 Generated with Claude Code

anth-volk and others added 30 commits November 13, 2026 23:40
…ataset rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rea dataset (failing)

A simulation run the simulation service starts on its own may later be
named by a local-area report, which reads the area columns. Today the
plan copies them only for constituency and local-authority regions.

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

The output plan copies UK_AREA_DATASET_COLUMNS for such a simulation too,
by C1 · 23's rule (calculates_on_local_area_dataset), whether it is
planned for a report or alone, so its run's file holds what a local-area
report reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the local-area dataset

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

# Conflicts:
#	projects/policyengine-simulation-executor/tests/test_stage12_output_planning.py
Under the shared-report keys, a report whose baseline and reform have the
same policy and dynamic names one simulation, and one run of it, on both
sides. The contract refuses that today: ReportRunIds requires three
different runs, and ReportExecutionInput different execution IDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReportRunIds requires only that the report run differ from its simulation
runs, and names_one_simulation_run says when both sides are one run.
ReportExecutionInput lets the two inputs share an execution ID when they
are the same simulation, policy included.

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

Its run's poverty and inequality rows must be one set, as for any run,
not one per side with the same keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its poverty and inequality rows come from the baseline side only, so the
completion inserts no two rows with one key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sides (failing)

It must start one worker for that run, or read its stored output once,
and aggregate the one output as both sides. Today it starts a worker per
role, the second of which finds the run held, and reads a stored file
twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator starts the baseline's worker only, or reads the stored
output once, and gives the reform the same output described for its role;
the artifact both sides name is read and decoded once.

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

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

Per C1 · 24's answer 5: the coordinator waits for a run another report
run's worker, or an earlier attempt's worker, holds, never cancelling or
releasing it, and reads it from its stored file once it succeeds. It fails
such a run only when its holder's call ended without an outcome, and only
while the run still records that call. A failing, cancelled or pre-empted
coordinator leaves its workers and unstarted runs as they are.

The existing tests of the earlier rules change with them: a pre-empted
coordinator no longer cancels its workers, a restart no longer releases
their runs, and a failed report run no longer fails its other runs. The
release-based restart tests are replaced by ones that wait.

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

A coordinator waiting for a run another call holds fails it, once that
call ended without an outcome, only while the run still records that call
and is unfinished.

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

A worker whose start Modal refuses fails the report run and leaves its
run pending with no call, for another report run naming it to start.
No coordinator releases a run any more, so the release write's tests go,
and a missing run raises for the new conditional failure instead.

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

Per C1 · 24's answer 5 (2026-11-14):
- The coordinator starts a worker on each run pending with no call, and
  waits for a run another call holds (shared_simulation_runs.WaitedRun):
  it probes that call, and once it has ended reads the run again. A run
  that succeeded is read from its stored file and checked as a reused
  run is; one that failed fails the report run with its own failure; one
  still unfinished under the ended call is failed while it records that
  call (fail_simulation_run_held_by, modal_call_ended).
- A pre-empted coordinator cancels no worker, and its restart waits for
  its earlier workers. A failed or cancelled coordinator fails its report
  run only; its workers finish, and a refused start leaves its run
  pending. A worker's run is failed only for its own failure.
- release_simulation_run goes: nothing releases a run any more.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per C1 · 24's answer 4: a simulation's stored plan holds every column
that doesn't depend on the other side, the area and US budget columns,
the labour-supply responses only when its own policy turns them on, and
never the cliff columns; US runs always copy the two inputs .py's
labour-supply output reads. Each worker is given its own simulation's
stored plan, and a side that stores no responses is read with zeros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per C1 · 24's answer 4 (2026-11-14): a simulation run's file depends on
its simulation alone. resolve_simulation_output_plan, now each run's
stored plan, holds every column that doesn't depend on the other side,
the area and US budget columns, the labour-supply responses only when
the simulation's own policy turns them on, the two US inputs .py's
labour-supply output reads from either side, and never the cliff
columns, so US national runs keep their region-group split.

The report-run coordinator gives each worker its simulation's stored
plan and keeps the report's own plan for the aggregation. Each artifact
is checked against its own simulation's plan, a reused or waited-for
file against what the report reads from its side (side_read_plan), and
a side storing no responses the report reads is read with zeros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared simulation runs never store the cliff columns, and cliffs come back
in a later stage as a calculation of each simulation run (the user,
2026-11-14). Until then such a report run fails before any worker starts,
with cliff_impacts_unavailable, and touches none of its simulation runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report run with include_cliffs true fails before any run is read or
started, with cliff_impacts_unavailable, which no reuse refusal matches,
so no simulation run is marked as refused. Cliffs return in a later stage
as a calculation of each simulation run (the user, 2026-11-14). A reuse
test that sent include_cliffs only to check that options reach the SPM
resolver now sends the SPM selection alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1, bug 1: a failed probe of a held call, or of the
coordinator's own worker, was taken as the call ending, failing a healthy
shared run. It must count only once Modal reports the call ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1, bug 1: Modal's client raises for a lost connection too,
so a failed probe of a held call, or of the coordinator's own worker,
failed a healthy shared run. WaitedRun and the new ProbedWorkerCall now
ask Modal for the call's state, as the stuck-run sweep does
(modal_call_state, a CoordinatorDependencies seam), and keep waiting,
within the wait window, while Modal reports the call running or can't
say.

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

Review round 1, bug 2: a run another call held is read without the check
a reused run gets, that both sides name one catalog dataset and region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1, bug 2: as for a reused run, a run another call held must
name the catalog dataset and region of the report run's other simulation
run, or the report run fails with reused_simulation_run_mismatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With shared simulation runs, new report runs may keep naming, and waiting
for, a run whose call Modal never ends, so the sweep's 24-hour age limit
must list and fail a simulation run whatever report runs name it. Its
other passes keep leaving such a run to their coordinators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With shared simulation runs, new report runs may keep joining, and
waiting for, a run whose call Modal never ends, so the exclusion of runs
an unfinished report run names would keep the 24-hour age limit from
ever failing it. list_active_runs and fail_stuck_run take
regardless_of_report_runs, which only the age limit passes; the sweep's
other passes still leave such a run to its coordinators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Docstrings of the worker, the reuse module, the child wait, the store's
  completion and failure writes, and the Stage 12 doc no longer describe
  releasing a run or stopping unfinished workers for a report's sake.
- CoordinationCore.stop_children, which nothing calls any more, goes.
- recorded_run_failure builds a failed run's detail for both the
  coordinator's prepared runs and its waited-for runs, with one
  simulation_run_failed code.

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

Review round 1: since the contract accepts one simulation run on both
sides of a report run, a comparison-path report with one execution ID
would claim its record and fail later on its child records' keys. It
must be refused before anything is written, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only a report run may name one simulation run on both sides; the
comparison path keys its child records by execution ID, so it refuses
such an input before writing anything, as the contract did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 21 commits November 14, 2026 02:30
…shared

Review round 1's missing cases, each passing against the code already
there: a waited-for run whose call ends leaving it to another call is
waited for under that call; one left pending with no call gets this
coordinator's own worker; and one run on both sides that another call
holds is waited for and read once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per the user (2026-11-14), C1 · 26 covers the household function too.
Like the coordinator, it must wait for a side another call holds, after
the sides it can claim, using the run once it succeeds and failing it only
once its call ended without an outcome; fail only the runs it holds; and
calculate a run named on both sides once, which the household contract
must accept when both sides are one calculation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per the user (2026-11-14), C1 · 26 covers the household function too:
- A side whose run another call holds is waited for once the sides this
  call can claim are done (wait_for_held_run with a WaitedRun, within
  HOUSEHOLD_SIDE_WAIT_SECONDS of the call's start): used once it succeeds,
  its recorded failure is the side's, and it is failed only once Modal
  reports its call ended. A side still held when the window closes fails
  the report run and is left to its call.
- A run named on both sides is calculated once; the household contract
  accepts one run on both sides when both are one calculation, and the
  simulation service's builder test now checks two policies are refused.
- A failing call fails only the runs it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e them (failing)

A report run's worker looks its current-law US baseline up in the
precomputed cache under the digest of the plan its simulation alone
decides, but the precompute plans its baselines as one side of a
current-law report, so the digests differ and every lookup misses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each precomputed side is planned with resolve_simulation_output_plan, so
a report run's current-law US national baseline finds its precomputed
files again. A comparison-path call still plans its baseline with its
report's plan, so its baseline now calculates all 20 region groups; the
first deployment computes the 60 baseline files under their new
identities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A budget window's later year names simulation runs that stay pending
with no call until the earlier years end, which can take longer than the
age limit; the age limit, which ignores the report runs naming a run,
fails them although no call has held them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The age limit fails a simulation run whatever report runs name it only
when the run records a call, which may never end while new report runs
keep joining it. A run no call has claimed, such as a budget window's
later year waiting for the earlier years, waits for the report runs
naming it, as it did before shared runs.

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

A failed probe of a held run's call asks Modal for the call's state even
when the run's row already says the run finished. Also covers what was
untested: Modal unable to say a call's state keeps the wait going, an
ended call's unfinished run is failed under that call, and an older
stored file lacking a column the report reads is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A probe of the holding call that raises now reads the run first: a run
that finished, or moved to another call, is taken from its row, and only
one still unfinished under the probed call asks Modal for the call's
state. The module docstring no longer says the stuck-run sweep never
judges a run an unfinished report run names, which its age limit does.
The report's own plan is now required where stored files are checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forced re-run's simulation runs are extra runs no other report run
joins (C1 · 24, answer 3), so its coordinator finds both pending with no
call, starts a worker on each and waits for no other call, with no
marker telling it the run was forced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A household side's run may be held by another household function, which
records the run and goes on to its other side and its own waits. The
wait probes that call until it ends, so a side whose run already
succeeded, or failed, waits out the whole window, and a failed run then
fails the report run as still running under another call rather than
with its recorded failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The call holding a household side's run is another household function,
which goes on to its other side and its own waits once it has recorded
the run. The household wait now reads the run before each probe
(WaitedRun.read_run_first) and takes a run that succeeded, failed, was
left unstarted or moved to another call at once, so household report
runs whose sides overlap no longer wait out their windows, and a failed
run fails the report run with its recorded failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The household function claims and calculates a waited-for side left
pending with no call, waits under a later call for a side moved to it,
waits once for one held run on both sides, and counts its own
calculation in its wait window. A failed probe of a run moved to another
call follows it without asking Modal, and a waited-for file lacking a
column the report reads is refused. All pass against the code already
there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason of a side still held when the window closes says the run is
still running "after 780 seconds", but the window counts from the call's
start, so the wait itself was shorter. A side whose run succeeds on the
window's last probe still succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the wait window closes, the household function reads the side's
run again: one that succeeded or failed meanwhile is taken as it is,
and one still held says it was still running when the window closed,
counted from the call's start. One helper now answers for every side
whose run another call finished, which removes a branch nothing reached.
The household functions' Modal timeout moves to stage12_timeouts, where
the wait window is derived from it, and the stale docstrings and README
text on the household function's failures are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four household tests still expected a refused or mismatched payload to
fail the report run's simulation runs; since the household function
fails only the runs it holds, they expect the report run alone to fail,
and run_household_report's docstring says so. New tests cover a side
whose run fails on the wait window's last probe, and a wait that reads
the run first finding it moved or unstarted without a probe. The
household finishing time's comment says no write leaves a held run
pending with no call today, and the coordinator test of fresh runs is
named for the state it reads.

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