Skip to content

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

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

anth-volk wants to merge 30 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 names the call's two simulation runs as its baseline and reform, then claims the report run under its own Modal call ID, then its Stage 12 report. The claim takes a pending or running report run only while it records no Modal call or this call's own: the simulation service records the call it starts while the run is still pending, and Modal restarts a pre-empted call under the same ID. A report run that records another call, 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.
  • labor_supply_responses: one row with the labour supply response's earnings changes from the income and substitution effects and their total, the revenue change, the two relative responses, and the baseline, reform and changed weekly hours with the change split into the two effects. labor_supply_decile_responses: one row per decile, with the income and substitution responses per household and relative to earnings; .py reports relative responses only for deciles above zero, so a decile without one has null relative columns. An output with no labour supply response stores neither.
  • On the report run itself, set by the same UPDATE that marks it succeeded: the baseline's and reform's cliff gap and cliff share (four columns, all null when the output has no cliff impact), the SPM receipts (jsonb) 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, connected as the Modal worker role, whose UPDATE covers only its runs' status, call, error, timing and result columns. That change keeps the foreign keys between the two services' tables, so each test's runs name real catalog rows, simulations and a report. 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, including a pending report run that records another call, completions and failures; a result whose insert fails leaving the run running, with none of its rows and none of its own result columns; 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, the poverty rates by group and the labour supply response by decile; an output without labour supply or cliff impact; a report run that names other simulation runs than its call; 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 and result tables this writes, and the Modal worker role's declared privileges, including its column-level UPDATE.
  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 8 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>
The US and UK single-simulation functions in the Stage 12 Modal app now
pass `modal.current_function_call_id()` to the runtime, so a call that
names a report run can record its simulation run under its own call ID.
The tests load the app against a stand-in `modal` module, which has no
`current_function_call_id`, and the `_invoke` helper set one only for
the coordinator. The US and UK cases of the four tests that call each
Stage 12 function through `_invoke` therefore raised `AttributeError`
before reaching the code they test.

`_invoke` now gives the stand-in a call ID for the workers too, unless
the test already set its own, so the test that checks a worker passes
its own call ID still sees its own value. Production code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation service now records the Modal call it starts on the report
run while the run is still pending. A claim therefore accepts a pending or
running report run only while it records no call or the claiming call's
own; a run recording another call belongs to that call, and the
coordinator returns the run's status without starting anything, as it
already does for a run another call holds.

Simulation runs keep moving to the newest call: the coordinator starts
their calls, and records nothing on them while they are pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `ReportRunWriter` protocol still said any pending report run is
claimed, and a store test was named for the simulation service marking a
run running, which it no longer does. The protocol now states the store's
rule, and the test is named for what it checks: a running report run with
no call recorded is claimed and keeps its first start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PolicyEngine/policyengine-api#4162's rebuilt schema changes three things
this branch writes:

- A report run names its two simulation runs, in
  `baseline_simulation_run_id` and `reform_simulation_run_id`, and
  `report_run_simulation_runs` is gone. `require_simulation_runs` reads
  those two columns: a report run that names other simulation runs raises
  ValueError, and a missing one LookupError.
- `report_run_comparisons` is gone. The cliff impact is four columns of
  the report run, the SPM receipts its `jsonb` column and the data
  version its own column; the completion's `UPDATE` sets them with the
  run's success, so they roll back with it. The labour supply response
  is one `labor_supply_responses` row and one
  `labor_supply_decile_responses` row per decile, inserted with the other
  result rows. .py reports relative responses only for deciles above
  zero, so a decile without one has null relative columns.
- The foreign keys between the two services' tables stay, so the tests'
  simulation runs name a real catalog version and dataset, and are
  inserted before the report run that names them.

The completion's `UPDATE` sets only columns the Modal worker role may
update.

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

- The Modal worker role is refused an `UPDATE` of what a run is (a report
  run's baseline simulation run, a simulation run's year) and of a report
  run's markdown, so a database where it still holds `UPDATE` on whole
  tables fails the store tests.
- The economy output's labour supply earnings changes and weekly hours
  now have different values, so a mapping that swapped one with the
  other fails.
- An absent SPM receipt is checked to be SQL NULL, not JSON null, which
  reads back as None too.
- `require_simulation_runs` compares the report run's two simulation runs
  by name and says which runs the report run names. The cliff columns are
  built in one branch, and docstrings say a result is the report run's
  own columns as well as rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test of the Modal worker role's column grants now names each refused
update as a case with functions that pick the row and the value from the
seeded runs, instead of a placeholder string. The in-memory report run
store's error message names the simulation runs, as the store's does.
`database-access.md` no longer calls the Stage 12 comparison tables the
only tables this repository uses: the Public API owns those and the run
tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The column-grant test now updates `updated_at` through the same
statement first: the role may update it, so the refusal that follows is
of the refused column, not of the row the statement selects.
`database-access.md` lists the Stage 12 comparison tables and the run
tables as the two sets the Public API owns, says deployment validation
doesn't compare the run tables' mappings with the live schema, and says
both sets stay with the Public API once this repository has its own
migrations.

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