Repository navigation
Conversation
…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>
2 tasks done
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #991
Same-Repository Draft PR Check
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
/simulationsand/reportsroutes, a later change, will.How a call names its runs (
policyengine_simulation_contract)Stage12InvocationContext.report_run(new, optional): aReportRunIdswithreport_run_id,baseline_simulation_run_idandreform_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, inpolicyengine_stage12_persistence), connected as the Modal worker role.SELECTthat 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.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.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 withInputCancellation, and never runs a cancelled call again. Neither is anException.report_cancelled), because nothing would finish them otherwise; so does one cancelled while it claims its Stage 12 report.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.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(entityhousehold):household_tax,household_benefitsand, for the US only,household_state_income_tax(change),household_net_incomeandhousehold_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'sis_taxflag.decile_impacts: the change inhousehold_net_incomeby income decile, with a nulldecile_variable, and (UK) by wealth decile, withdecile_variablehousehold_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_variablenames the variable that selects a group (age,is_male,race) andfilter_groupthe 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_impactsandlocal_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_versionis the report run'scountry_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_URLandREPORT_RUN_TEST_SEED_DATABASE_URLare 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 intoreport_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 secretpolicyengine-modal-worker-database, which an operator creates in thestagingandmainModal environments (below). Wiring:modal.Secret.from_name(..., required_keys=["MODAL_WORKER_DATABASE_URL"])attached throughsecrets=tocoordinate_report,run_single_simulation_usandrun_single_simulation_ukinsrc/modal/v2_app.py. Not verified: the secret does not exist yet.STAGE12_DATABASE_URL,STAGE12_ARTIFACT_BUCKET(unchanged): the Modal secretstage12-evaluation-runtime, synced from Secret Manager by each deploy'sstage12-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
report_run_comparisonsthis writes, and the Modal worker role's declared privileges.policyengine_v2_modal_workercreated, with a password, in the staging and production v2 databases (an operator step), so the Public API's deploy grants it its privileges.policyengine-modal-worker-databasewith keyMODAL_WORKER_DATABASE_URLin thestagingandmainModal 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, andmodal deployfails without it, so this comes before the merge.The Modal secret (operator steps, for each of
stagingandmain)Name
policyengine-modal-worker-database, keyMODAL_WORKER_DATABASE_URLand no other.Value: a
postgresql://URL that logs in aspolicyengine_v2_modal_workerto databasepostgresof that environment's v2 database, in the same format as the existingSTAGE12_DATABASE_URL:postgresql://policyengine_v2_modal_worker.<project-ref>:<password>@<pooler-host>:5432/postgres?sslmode=require. The scheme must bepostgresql://; the code selects the driver.Create it from
projects/policyengine-simulation-executor, without echoing the value:Check it without printing it:
modal secret list --env staginglists the name;psql "<url>" -Atc 'select current_user'(from a file holding only the URL) prints the role;select count(*) from public.report_runssucceeds andinsert into public.report_runs …is refused. The first deploy after merging must passmodal deploy, which checks the required key.🤖 Generated with Claude Code