Repository navigation
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #4162 +/- ##
===========================================
+ Coverage 53.93% 88.41% +34.48%
===========================================
Files 281 293 +12
Lines 16261 17498 +1237
Branches 2629 2869 +240
===========================================
+ Hits 8770 15471 +6701
+ Misses 6760 1318 -5442
+ Partials 731 709 -22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Nov 3, 2026
Add simulation_runs, one row per run of a simulation with the catalog, country package and PolicyEngine.py versions it used, its status, Modal call and output file; and report_run_simulation_runs, the simulation runs each report run combines. Poverty and inequality belong to a simulation run, the other result tables name the two simulation runs they compare, report runs record their Modal call and the two results without a table, and household jobs calculate one household for one year under one policy. Revision 2ffd50f47a2b was autogenerated, with two documented enum-type corrections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation service and its Modal workers write simulations, reports, household jobs, runs and results; the Public API writes users, saved items, policies, households and the catalog. Revision 7a83a87d029a drops the 14 foreign keys from the first group to the second, keeping each column as a plain UUID that the writing service checks, and the models drop the ORM relationships across the two groups. Keys within a group stay. The revision is autogenerated with no corrections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation API's economy output carries fields with no column in the v2 result tables. Revision 7f43d2c7c3c6, autogenerated with no corrections, adds report_runs.spm_provenance and data_version, the congressional districts' winner, loser and no-change shares, and the UK areas' disclosure tier and interval bounds. The detailed budget names neither a program's entity nor whether it is a tax, so both become nullable in program_statistics, whose unique key treats a missing entity as equal. The revision-by-revision downgrade checks move from the v2 lifecycle test, which reached the test-file length limit, to the simulation runs integration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pull request can now declare a database role before an operator creates it, by listing the role in ROLES_AWAITING_PROVISIONING. While a listed role is missing, reconcile_v2_privileges.py apply and verify skip it and name it in their output and job summary instead of failing the deploy; once the role exists, they grant and check its privileges. A missing role that is not listed still blocks every grant and fails verification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The privilege manifest declares two roles an operator will create, both awaiting provisioning so the deploy skips them until they exist: - policyengine_v2_simulation_service, the simulation API on Cloud Run, creates simulations, reports, their runs, report run members and household jobs (SELECT, INSERT), and reads the policy, household and catalog rows a run needs; - policyengine_v2_modal_worker, its Modal workers, updates simulation runs, report runs and household jobs, inserts results, and reads the simulation definitions and the rows a run needs. The Public API's runtime role reads the simulation tables and results, and writes none of them. New integration tests run these writes as each role, and CI runs them and the simulation runs migration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A budget-window report run compares one pair of simulation runs per year, so each comparison result table's unique key now includes the pair after the report run. decile_impacts.decile_variable names the variable that groups households (UK wealth deciles share the income variable and entity), and poverty.filter_group names the group one filter variable selects (age: child, adult, senior); both join their table's unique key. Revision 2e3de7c572ed is autogenerated with no corrections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation and a household job may name a dynamic, which a run reads, so the simulation service and Modal worker roles get SELECT on dynamics. reports.user_id is the owner the Public API names and checks, since the simulation service can't read users. The manifest and the Alembic guide state that the workers' UPDATE is table-level. verify now names a skipped role in the job summary as apply does, the catalog publication test checks that the catalog rows a simulation and a report name survive republishing, and the guide's descriptions of the new revisions are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A budget-window report run compares one pair of simulation runs per year, and each pair's economy output has its own labour supply response, cliff impact, SPM receipts and data release. Revision dce0caa329cc, autogenerated with no corrections, moves those four values from report_runs to the new report_run_comparisons, one row per report run and pair, which the Modal workers insert and the Public API reads. The report run relationships are named for the rows they hold (simulation_run_members, comparisons, report_run_memberships), the simulation-side integration tests share their SQL helpers, and the Alembic guide says what deleting a user now leaves in reports.user_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify now writes one job summary section, names the roles it skipped, and says "Every other declared v2 table privilege is held" while it skips any, instead of claiming every privilege is held. New tests cover verify failing once a role awaiting creation exists without its privileges, and a Modal worker finishing a household job. The rules registry describes the skipped roles and the simulation API's role tests, and the Alembic guide notes that NULLS NOT DISTINCT needs PostgreSQL 15. Docstrings name PolicyEngine.py in full, and the report-run module says no managed role can run its row-locking operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check_declared_privileges no longer takes the roles awaiting creation: verify asks skipped_roles once and checks the other declared roles. The report-run module's note names the two operations that lock reports rows, and the tests check that the worker's household job update changed its row and that verify names the skipped role as skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test pins the number of tables the v2 metadata registers; this change adds simulation_runs, report_run_simulation_runs and report_run_comparisons, so it is 45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
v2plan/b1-1-simulation-runs
branch
from
November 3, 2026 22:39
c9c6eb6 to
d674032
Compare
anth-volk
added a commit
to PolicyEngine/policyengine-sim-api
that referenced
this pull request
Nov 3, 2026
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>
This branch was previously 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 #4161
Warning
Deploying this PR changes the staging and production v2 database schemas and table privileges. The release's v2 migration job adds three tables, moves the execution columns off
simulations, re-keys the result tables by run, adds the economy output's missing columns and drops 14 foreign keys. It needs the changed tables empty, which they are while no managed role can write them (see "Data"). It also declares two new database roles, which an operator creates (see "What this waits for before merging"), and changes two rules in the rules registry, so it needs a core developer's review.Summary
API v2's simulation tables recorded one execution per simulation. This PR makes every run a row, with the versions it used, so re-running a simulation or report keeps the earlier results, and prepares the tables for the simulation API (
PolicyEngine/policyengine-sim-api) and its Modal workers to write them.simulationskeeps only what to calculate. Its execution columns (status,error_message,started_at,completed_at,household_result) move to runs;output_dataset_idis replaced by the run's output file.simulation_runs(new): one row per run of a simulation, with the catalog version (tax_benefit_model_version_id),country_package_versionandpolicyengine_versionit used, the dataset and region it resolved, its status (pending,running,succeeded,failed, reusing thev2_simulation_statustype), Modal call ID, error, timings,household_resultfor a household run, andoutput_uriwithoutput_sha256for an economy run's output file. Checks: a finished run hascompleted_at; the output URI and digest are set together; the digest is 64 characters. Indexes for the newest successful run of a simulation on given versions, and for runs by status and start time.report_run_simulation_runs(new): the simulation runs a report run combines, each as itsbaselineorreform(new typev2_simulation_role), unique per report run, role and run. A budget window's run combines one pair per year.report_run_comparisons(new): one row per report run and compared pair of simulation runs, holding the parts of an economy comparison that have no result table: the labour supply response, cliff impact, SPM receipts and data release.report_runs(already one row per run with both versions) gainsmodal_call_idand an index by status and start time.povertyandinequalitydescribe one simulation, so they belong to a simulation run (simulation_run_id, cascade). The other result tables (budget_summary,program_statistics,decile_impacts,intra_decile_impacts,congressional_district_impacts,constituency_impacts,local_authority_impacts) still belong to a report run and name the two simulation runs they compare (baseline_simulation_run_id,reform_simulation_run_id) instead of two simulations; each row is unique within its report run and pair, so a budget window stores one set per year.aggregatesandchange_aggregatesare unchanged.decile_impacts.decile_variablenames the variable that groups households when it isn't the income variable's own deciles (UK wealth deciles:household_wealth_decile), andpoverty.filter_groupnames the group a filter variable selects (age:child,adult,senior), as PolicyEngine.py's outputs name them; both join their table's unique key, where missing values count as equal (NULLS NOT DISTINCT).congressional_district_impactsgains the winner, loser and no-change shares;constituency_impactsandlocal_authority_impactsgain the UK disclosure fields (disclosure_tier, checked to bereportedorapproximate, and the four_lower/_upperinterval bounds);program_statistics.entityandis_taxbecome nullable, because the detailed budget names neither. Every field of the simulation API's single-year economy output now has a column.household_jobscalculates one household for one year under one policy: it gainshousehold_id,year(1900–2200), the catalog and package versions,modal_call_id,updated_atand the same completion check;request_datanow holds the calculation's options, such as axes. Nullpolicy_idmeans current law.simulations,reports,household_jobs, the run tables and the results; the Public API writes users, saved items, policies, dynamics, households and the catalog. The 14 foreign keys from the first group to the second are dropped, and the columns stay as plain UUIDs that the writing service checks (reports.user_id, the owner the Public API names, is checked by the Public API). Foreign keys within each group stay; the ORM relationships across the groups are removed. Deleting a user no longer clearsreports.user_id; a saved report's owner is its user association, which the deletion removes.simulation_runsis also a v1 Cloud SQL table name; the v2 metadata validation now treats it as a name the two databases share, as it doessimulations.No table is dropped. No route, request or response changes: nothing serves these tables yet.
Table privileges
policyengine_api/data/v2/privileges.pydeclares two new managed roles, and the Public API's runtime role's reads:policyengine_v2_runtime(the Public API)policyengine_v2_simulation_service(the simulation API on Cloud Run)policyengine_v2_modal_worker(its Modal workers)simulations,reports,report_run_simulation_runssimulation_runs,report_runs,household_jobsreport_run_comparisonsand the nine result tablespolicies,dynamics,households,tax_benefit_models,tax_benefit_model_versions,regions,datasets,parameters,parameter_valuesaggregates,change_aggregates)The catalog writer and the Stage 12 role keep exactly their privileges. The privileges are table-level, as for every managed role: the database doesn't limit the workers'
UPDATEto the status and result columns, or to their own run; the workers' code does.Declaring a role before it exists.
reconcile_v2_privileges.py applygrants nothing, andverifyfails, when a declared role is missing, so declaring these roles would otherwise fail every deploy until an operator creates them. Both roles are listed in a newROLES_AWAITING_PROVISIONING: while a listed role is missing,applyandverifyskip it and name it in their output and job summary ("Declared roles not created yet, skipped until an operator creates them"), andverifyreports "Every other declared v2 table privilege is held". Once the role exists, the next deploy grants its privileges andverifychecks them. A missing role that is not listed still blocks every grant and fails verification. A follow-up PR removes the two roles from the list once they exist in both environments. The rules registry'sapi.v2.deployed-privileges-matchandapi.v2.runtime-privileges-tested-as-runtime-rolenow describe this and the new role tests;docs/generated/rules.mdis re-rendered.tests/integration/test_v2_simulation_role_privileges.pyruns the simulation API's writes as each role (the simulation service creates the rows but can't update a run or write a result; a worker updates its run and household job and inserts results but can't create a simulation or change a result; the Public API reads but can't write). CI's v2 integration job now runs it, and the v2 Alembic job runstests/integration/test_alembic_v2_simulation_runs.py.Revisions
All five chain onto
c614b6bcccfc, the currentmasterhead, and were autogenerated withuv run alembic -c alembic-v2.ini revision --autogenerateagainst a disposable PostgreSQL 17 database at each parent. Whenmastergainede86f19a6c4da,3869eb814a90andc614b6bcccfc,2ffd50f47a2bwas generated again on top of each, with--rev-id 2ffd50f47a2b; the operations were the same each time.2ffd50f47a2b(parentc614b6bcccfc): simulation runs, report run members, results keyed by run, household jobs. Two post-generation corrections, each commented in the revision and covered by tests:simulation_runs.statusreusesv2_simulation_status, which the baseline created forsimulations.status. The generatedcreate_tablewould create the type again and fail (DuplicateObject), so the column usespostgresql.ENUM(..., create_type=False).v2_simulation_roleafter its table, because Alembic does not drop a table's enum type with it.7a83a87d029a: drops the 14 cross-service foreign keys; its downgrade restores them. No corrections.7f43d2c7c3c6: adds the economy output's missing columns and relaxesprogram_statistics.entityandis_tax. No corrections.2e3de7c572ed: addsdecile_variableandfilter_groupand rebuilds eight unique keys with the run pair or group. No corrections.dce0caa329cc: createsreport_run_comparisonsand drops the fourreport_runscolumns it takes over, which the earlier revisions added and nothing wrote. No corrections.V2_ALEMBIC_HEAD_REVISIONis nowdce0caa329cc. The Alembic guide documents the five revisions and the new roles.NULLS NOT DISTINCTneeds PostgreSQL 15 or later.Data
The upgrades add required columns without server defaults to
household_jobsand the result tables, so they need those tables empty. They are while no managed role holds a write privilege on them (onmaster, the runtime, catalog writer and Stage 12 roles hold none on any table this PR changes). On a table with rows the upgrade fails inside its transaction and the release stops before deploying; nothing is migrated or deleted. The downgrades work only whilesimulations,household_jobsand the result tables are empty, while no simulation-side row names a Public API row that doesn't exist, and while no report run holds rows of more than one pair.Compatibility with the previous Public API revision
The deploy upgrades the v2 schema before the new release starts, so the previous release runs against these revisions for a while, and after a rollback. It can't read or write any table they change: its managed roles hold no privilege on them, except the catalog writer, which counts the rows of
simulations,reportsandreport_runs(a count reads no column).tests/integration/test_alembic_v2_simulation_runs.pychecks that the revisions add the three tables and change only the simulation-side tables, and downgrades below each of them and upgrades again. The runtime role's new reads only add privileges.Environment variables
This PR reads no new environment variable. The CI changes only add test files to existing steps of
alembic-v2-check.yml(through.github/scripts/test_alembic_v2_lifecycle.sh) andv2-integration-check.yml, whose database URLs,V2_ALEMBIC_DISPOSABLE_TESTandDEPLOYMENT_ENVIRONMENTare literals in the jobenv:. The existing v2 seed workflow (.github/workflows/seed-v2-database.yml, called frompush.ymlwithsecrets: inheritanddeployment_environmentstagingorproduction) applies the revisions and the privileges. Its job runs in that GitHub environment and reads:V2_MIGRATION_DATABASE_URL: GitHub environment secret, present instagingandproduction; passed as stepenv:.V2_SUPABASE_PROJECT_REF,V2_SUPABASE_ENVIRONMENT: GitHub environment variables, present in both; jobenv:.PRODUCTION_V2_SUPABASE_PROJECT_REF: repository variable, present; jobenv:.DEPLOYMENT_ENVIRONMENT: the workflow input.GITHUB_STEP_SUMMARY: set by GitHub Actions.The two new roles' credentials are created and given to the simulation API outside this repository (below); until an operator confirms those steps, they are not verified.
What this waits for before merging
report_runs), and JSON columns inreport_run_comparisonsfor labour supply, cliff impact and the SPM receipt (instead of row tables).SHOW server_version;in each project's SQL editor), forNULLS NOT DISTINCT.down_revisiononto the new head.policyengine_v2_simulation_serviceandpolicyengine_v2_modal_workerin the v2 database (no superuser, database, role-creation, replication or RLS-bypass attributes; no role membership; no table grants by hand), generates each password without printing it, and stores each URL in Secret Manager in the simulation API's project (simulation-service-staging-database-url,modal-worker-staging-database-url). Then the "Seed v2 database" workflow runs forstaging(by hand, or with the next release), which grants and verifies the two roles' privileges.…-production-database-url, workflow environmentproduction).ROLES_AWAITING_PROVISIONING, so that a missing role fails the deploy again.SIMULATION_DATABASE_URL(from the first secret, Connect the Simulation Entrypoint to the Public API's database with its own role policyengine-sim-api#898) and its Modal workers with the Modal secretpolicyengine-modal-worker-database(keyMODAL_WORKER_DATABASE_URL, from the second).pg_rolesandpg_auth_membersshow the roles' attributes and no memberships; the seed workflow's summary shows the roles' grants andEvery declared v2 table privilege is held;has_table_privilegespot checks (for example the simulation service canINSERTbut notUPDATEsimulation_runs);gcloud secrets versions listshows each secret's enabled version.Testing
Each commit was written red-green: the tests first, seen failing for the expected reason, then the code. Only the tests each commit adds or modifies were run, each by node ID, against a disposable
postgres:17database namedpolicyengine_v2_alembic_test(set up asalembic-v2-check.yml's service container is); the full suite runs in CI.52b01a1ff(simulation runs, results by run, household jobs). Seen failing onmaster's code: the new model tests (ImportError: noSimulationRun), the revision and chain tests (no revision), and both tests then intests/integration/test_alembic_v2_simulation_runs.py. Passing after the last rebase: 25, including the lifecycle test, the previous-revision compatibility tests and the parameter tree and search file revision tests, whose head assertions now name their own child.019907f7e(cross-service foreign keys). Seen failing: the four foreign key and relationship tests, the revision test, the chain test and the live-schema test; and, after the catalog publication test, a downgrade that couldn't restore a key, because the publication tests'TRUNCATE tax_benefit_models CASCADEno longer reachedsimulationsandreports(the fixture now names them). Passing: 41, including the 18 report-run unit tests whose shared helper no longer uses the removed relationships.0ec365a4b(economy output fields). Seen failing: four model tests (KeyErrorper new column), the revision and chain tests, and the result-table checks. Passing: 14. The commit moves the revision-by-revision downgrade checks intotests/integration/test_alembic_v2_simulation_runs.py, because the lifecycle test file had reached its length limit.13669e0e9(skipping a role not created yet). Seen failing: four unit tests (TypeError: noroles_awaiting_creation, noskipped_roles) and two integration tests. Passing: 6.b874e2ff1(the two roles' privileges). Seen failing: six manifest tests, the three role tests (ImportError: noMODAL_WORKER_ROLE) and two workflow tests. Passing: 11.205b2a6cc(wealth deciles colliding with income deciles; PolicyEngine.py's poverty breakdowns colliding onpoverty's key; a budget window storing comparison rows for one year only; seen failing: 9 tests, passing: 17) andb5cd8d742(the simulation API's roles couldn't readdynamics;verifynamed a skipped role only on stdout; seen failing: 3, passing: 12). Round 2 found one, fixed in601561f2e(a budget window had one place for each year's labour supply, cliff impact, SPM receipts and data release; seen failing: the model tests on import, passing: 18). Round 3 found none; its quality findings are addressed in1af1cf472(seen failing: 2 verify tests; passing: 11), and the extra round on that commit found one inaccurate docstring, fixed with its quality findings in23bfc02e5(passing: 8).3fdbd8b1c,dc617db3e,e7c362b16) failed one test,tests/unit/v2/test_import_side_effects.py::test_importing_v2_modules_opens_no_network_and_creates_no_files, which pins the number of v2 tables;d674032a2counts the three new tables (45). Seen failing locally on the previous commit, passing after. No CI ran onc9c6eb6d3, which conflicted withmaster. CI ond674032a2(pushed 2026-11-05) was still running when this was written, with no failure so far.alembic -c alembic-v2.ini checkat head:No new upgrade operations detected.Each revision downgrades and upgrades again, anddowngrade baseleaves nov2_*enum type.ruff format --check .,ruff check .,shellcheck,uv run --frozen --extra dev mypy,python scripts/run_quality_guards.py,python -m scripts.rules validateandpython scripts/export_migration_contracts.py(no generated changes): pass.No inline ruff suppressions are added.
🤖 Generated with Claude Code