Skip to content

Record each run of a v2 simulation and report, and key results by run - #4162

Draft
anth-volk wants to merge 23 commits into
masterfrom
v2plan/b1-1-simulation-runs
Draft

anth-volk wants to merge 23 commits into
masterfrom
v2plan/b1-1-simulation-runs

Conversation

@anth-volk

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

Copy link
Copy Markdown
Collaborator

Fixes #4161

Warning

Deploying this PR changes the staging and production v2 database schemas and table privileges. The release's v2 migration job adds four tables, moves the execution columns off simulations, re-keys the result tables by run and adds the economy output's missing columns. 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 four rules' entries in the rules registry (the three privilege rules, and the allowed table-model imports of api.v2.pure-modules-no-database for the new run_results module), 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.

  • simulations keeps only what to calculate. Its execution columns (status, error_message, started_at, completed_at, household_result) move to runs; output_dataset_id is replaced by the run's output file.
  • simulation_runs (new): one row per run of a simulation for one year, with the catalog version (tax_benefit_model_version_id), country_package_version and policyengine_version it used, the dataset and region it resolved, its status (pending, running, succeeded, failed, reusing the v2_simulation_status type), Modal call ID, error, timings, household_result for a household run, and output_uri with output_sha256 for an economy run's output file. Checks: a finished run has completed_at; the output URI and digest are set together; the digest is 64 characters; the year is 1900–2200. Indexes for the newest successful run of a simulation in a year on given versions, and for runs by status and start time.
  • report_runs (already one row per run with both versions) names the two simulation runs it compares: baseline_simulation_run_id (required) and reform_simulation_run_id (empty for a household report with only a baseline), each a foreign key to simulation_runs with RESTRICT. It gains modal_call_id, an index by status and start time, and the parts of an economy comparison that have no result table: the baseline's and reform's cliff gap and share (four numbers, set together or not at all), the SPM receipt (spm_provenance, jsonb) and the data release (data_version).
  • labor_supply_responses and labor_supply_decile_responses (new): the labour supply response of a report run, one row per report run (the earnings changes from the income and substitution effects and their total, the revenue change, the two relative responses, and baseline, reform and changed weekly hours split into the two effects), and one row per report run and income decile (each decile's income and substitution responses, per household and relative to earnings).
  • Budget windows. reports.budget_window_size (1 to 75 years; an economy report with a year, ending by 2200) marks a budget-window report. budget_window_runs (new) holds one row per calculation of one: its report, catalog version, trigger, whether its years calculate their simulations again (recalculate), status (reusing v2_report_run_status and v2_report_run_trigger), error and times. Each year is a report run naming budget_window_run_id and year, at most one per year of a window run; a composite foreign key keeps the window run to the same report. What starts a window's later years is left to the simulation service's scheduling work.
  • Results keyed by run. poverty and inequality describe 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 it compares (baseline_simulation_run_id, reform_simulation_run_id) instead of two simulations; each row is unique within its report run. aggregates and change_aggregates are unchanged.
  • Each result row says what it measures. decile_impacts.decile_variable names the variable that groups households when it isn't the income variable's own deciles (UK wealth deciles: household_wealth_decile), and poverty.filter_group names 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).
  • The economy output's missing fields. congressional_district_impacts gains the winner, loser and no-change shares; constituency_impacts and local_authority_impacts gain the UK disclosure fields (disclosure_tier, checked to be reported or approximate, and the four _lower/_upper interval bounds); program_statistics.entity and is_tax become nullable, because the detailed budget names neither. Every field of the simulation API's single-year economy output now has a column.
  • household_jobs calculates one household for one year under one policy: it gains household_id, year (1900–2200), the catalog and package versions, modal_call_id, updated_at and the same completion check; request_data now holds the calculation's options, such as axes. Null policy_id means current law.
  • Foreign keys between the two services' tables stay. The simulation API and its workers write simulations, reports, budget_window_runs, household_jobs, the run tables and the results; the Public API writes users, saved items, policies, dynamics, households and the catalog. Each service writes only its own tables, and the foreign keys between them stay, so PostgreSQL checks every row a simulation-side row names when it is written. The new columns that name the Public API's rows get keys with the delete rules the same columns have on simulations: a simulation run's catalog version and dataset (RESTRICT) and region (SET NULL), a household job's household and catalog version (RESTRICT), and a budget-window run's catalog version (RESTRICT). reports.user_id gains an index, which account deletion's SET NULL searches. No privilege is needed for the checks: PostgreSQL runs them with the referenced table's owner's privileges.
  • API v1's alpha create_report_run takes the simulation runs a new report run compares, with its package versions, as one ReportRunInputs.
  • simulation_runs is also a v1 Cloud SQL table name; the v2 metadata validation now treats it as a name the two databases share, as it does simulations.

No table is dropped. No route, request or response changes: nothing serves these tables yet.

Table privileges

policyengine_api/data/v2/privileges.py declares two new managed roles, and the Public API's runtime role's reads:

Tables 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, budget_window_runs SELECT SELECT, INSERT SELECT
simulation_runs, report_runs, household_jobs SELECT SELECT, INSERT, and UPDATE of the call-start columns SELECT, and UPDATE of the run-state and result columns
The eleven result tables, including the two labour supply tables SELECT — SELECT, INSERT
policies, dynamics, households, tax_benefit_models, tax_benefit_model_versions, regions, datasets, parameters, parameter_values unchanged SELECT SELECT
Every other table (including aggregates, change_aggregates) unchanged — —

UPDATE is granted per column. The privilege declarations can now name a privilege on some of a table's columns (update_columns(...)), beside whole-table privileges, and PostgreSQL refuses a statement that sets any other column:

  • The Modal worker may set a run's status, modal_call_id, error_message, started_at, completed_at and updated_at, and its results: a simulation run's output_uri, output_sha256 and household_result; a report run's four cliff columns, spm_provenance and data_version; a household job's result.
  • The simulation service may set status, modal_call_id, error_message, completed_at and updated_at of the runs and household jobs whose Modal calls it starts: it records a call's ID once Modal accepts the call, and fails the run when Modal refuses it.

So neither role can change what a run is (its simulation or report, versions, dataset, region, year, simulation runs or budget-window run). The database doesn't limit a worker to its own run; the workers' code does. reconcile_v2_privileges.py apply reads column grants from pg_attribute, grants each declared column privilege a role lacks (one GRANT UPDATE (…) per role and table) and reports column grants the declarations don't name. verify checks each declared column with has_column_privilege, and fails when a role holds UPDATE on a whole run table, which would let it set every column. The catalog writer and the Stage 12 role keep exactly their privileges.

Declaring a role before it exists. reconcile_v2_privileges.py apply grants nothing, and verify fails, 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 new ROLES_AWAITING_PROVISIONING: while a listed role is missing, apply and verify skip it and name it in their output and job summary ("Declared roles not created yet, skipped until an operator creates them"), and verify reports "Every other declared v2 table privilege is held". Once the role exists, the next deploy grants its privileges and verify checks 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's api.v2.deployed-privileges-match now describes column grants, the whole-table check and the roles awaiting creation; api.v2.runtime-privileges-declared requires each column privilege to name columns of its table and never sit beside the same privilege on the whole table; api.v2.runtime-privileges-tested-as-runtime-role names the two simulation API roles. docs/generated/rules.md is re-rendered.

tests/integration/test_v2_simulation_role_privileges.py runs the simulation API's writes as each role: the simulation service creates the rows and records or fails a call but can't set a run's start, output or definition; a worker updates its run's state and results and inserts results but can't create a simulation, change a result or change what a run is; the Public API reads but can't write. Another test checks, for both simulation API roles, every column of the three run tables and budget_window_runs: an UPDATE of it succeeds exactly when the declarations name it for that role. CI's v2 integration job runs it, and the v2 Alembic job runs tests/integration/test_alembic_v2_simulation_runs.py.

Revisions

One revision, 1356f658347d (parent c614b6bcccfc, the current master head), autogenerated with uv run alembic -c alembic-v2.ini revision --autogenerate against a disposable PostgreSQL 17 database at its parent. It replaces the five revisions this PR had before; they were deleted in new commits, so the branch moves forward without a force-push.

Its three post-generation corrections are of one kind, each commented in the revision and covered by a test: columns of new tables reuse enum types the baseline created. The generated create_table would create each type again and fail (DuplicateObject), so each column uses postgresql.ENUM(..., create_type=False):

  1. simulation_runs.status reuses v2_simulation_status, which the baseline created for simulations.status;
  2. budget_window_runs.status reuses v2_report_run_status;
  3. budget_window_runs.trigger reuses v2_report_run_trigger.

V2_ALEMBIC_HEAD_REVISION is now 1356f658347d. The Alembic guide documents the revision and the new roles. NULLS NOT DISTINCT needs PostgreSQL 15 or later.

Data

The upgrade adds required columns without server defaults to household_jobs, report_runs and the result tables, so it needs those tables empty. They are while no managed role holds a write privilege on them (on master, 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 downgrade restores required columns of simulations and the result tables, so it works only while simulations is empty, which leaves no run or result row either; it drops household_jobs' new columns with their values.

Compatibility with the previous Public API revision

The deploy upgrades the v2 schema before the new release starts, so the previous release runs against this revision for a while, and after a rollback. It can't read or write any table the revision changes: its managed roles hold no privilege on them, except the catalog writer, which counts the rows of simulations, reports and report_runs (a count reads no column). tests/integration/test_alembic_v2_simulation_runs.py checks that the revision adds its four tables and changes only the simulation-side tables, and downgrades it and upgrades it 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), whose job env: sets the database URL and V2_ALEMBIC_DISPOSABLE_TEST as literals, and v2-integration-check.yml, whose job env: also sets DEPLOYMENT_ENVIRONMENT. The existing v2 seed workflow (.github/workflows/seed-v2-database.yml, called from push.yml with secrets: inherit and deployment_environment staging or production) applies the revision and the privileges. Its job runs in that GitHub environment and reads:

  • V2_MIGRATION_DATABASE_URL: GitHub environment secret, present in staging and production; passed as step env:.
  • V2_SUPABASE_PROJECT_REF, V2_SUPABASE_ENVIRONMENT: GitHub environment variables, present in both; job env:.
  • PRODUCTION_V2_SUPABASE_PROJECT_REF: repository variable, present; job env:.
  • 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

  • Review, including a core developer's review of the rules registry change.
  • PostgreSQL 15 or later in the staging and production v2 databases, for NULLS NOT DISTINCT: met. Supabase's project details give PostgreSQL 17 for both (staging 17.6.1.166, production 17.6.1.155; checked on 2026-11-06).
  • Other open PRs that add v2 revisions, including the foreign keys from saved reports and simulations to reports and simulations: whichever merges second points its first revision's down_revision at the other's head.
  • No operator step blocks the merge. The two new roles may be created before or after it. The required order:
    1. This PR merges. Each environment's deploy upgrades the schema, grants the runtime role's new reads, and names the two new roles as skipped.
    2. In staging, an operator creates the login roles policyengine_v2_simulation_service and policyengine_v2_modal_worker in 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 for staging (by hand, or with the next release), which grants and verifies the two roles' privileges.
    3. The same in production (…-production-database-url, workflow environment production).
    4. A follow-up PR removes the two roles from ROLES_AWAITING_PROVISIONING, so that a missing role fails the deploy again.
    5. Only after step 3 in an environment may the simulation API start connecting there with these roles: its Cloud Run service with SIMULATION_SERVICE_DATABASE_URL (from the first secret, Connect the Simulation Entrypoint to the Public API's database with its own role policyengine-sim-api#898; the GitHub environment variable naming the secret is SIMULATION_SERVICE_DATABASE_URL_SECRET_NAME) and its Modal workers with the Modal secret policyengine-modal-worker-database (key MODAL_WORKER_DATABASE_URL, from the second).
  • How to check each step without printing a secret: pg_roles and pg_auth_members show the roles' attributes and no memberships; the seed workflow's summary shows the roles' grants and Every declared v2 table privilege is held; has_table_privilege and has_column_privilege spot checks (for example the Modal worker can update simulation_runs.status but not simulation_runs.simulation_id, and holds no UPDATE on the whole table); gcloud secrets versions list shows 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:17 database named policyengine_v2_alembic_test (set up as alembic-v2-check.yml's service container is), with alembic check, ruff, ruff format and mypy before each commit; the full suite runs in CI.

Commits since d674032a2 (after merging master at 90b212b51, which added no v2 revision):

  • aa2875f40 (one revision). Seen failing: the revision chain test and the new revision source test. Passing: those two and the two integration revision tests.
  • 437560940 (foreign keys stay). Seen failing: the six new key, index and refusal tests. Passing: 16, including the integration tests whose helpers now store the catalog rows and household a row names.
  • d6693ad36 (report runs name two simulation runs). Seen failing: 16 tests, and the two report-run unit modules at import. Passing: 38.
  • 41b22d179 (typed results). Seen failing: 9 tests, and the model test module at import. Passing: 18, and the 18 report-run unit tests on SQLite with the new check.
  • c4742f225 (budget windows). Seen failing: 16 tests. Passing: 47.
  • c6bb73a87 (column-level UPDATE). Seen failing: three test modules at import and the two role tests. Passing: 17.
  • b990f6cb7 (one argument for the database's grants). Passing: the 15 changed planning tests and the four changed integration tests.
  • 357719fad (stale tests). Seen failing: the decile key test and the two window-run key tests. Passing: 5.
  • 56212cc00 (module split). Seen failing: api.quality.file-length (two files over 800 lines). Passing: the check, and all 79 tests this PR adds or changes.
  • 7642bd430 (every run column). Seen failing: the new per-column role test, with an undeclared column grant added to the disposable database; passing once it was revoked.
  • 772ca6b14 (the downgrade's stated precondition). Text only; the revision source test and alembic check pass.

On 772ca6b14, and again on a trial merge with master at f932adf71 (which adds no v2 revision or model, and merges without conflicts): alembic check reports no changes; lint-imports keeps all five contracts; python -m scripts.rules validate, python scripts/run_quality_guards.py and uv lock --check pass; ruff and ruff format are clean; mypy's configured scope (make typecheck-v2) is clean. policyengine_api/data/v2/ is outside that scope. Run explicitly on the 13 changed modules there and the revision, mypy reports 48 errors, each of a kind that master's versions of the same files already have (29 errors there): SQLModel's Field overloads don't accept sa_column, and autogenerated revisions reference sqlmodel.sql, as master's own revisions do.
Three review rounds ran on the rework, plus a search for existing tests the schema change would break:

  • Round 1 and the search found three failures, fixed in 357719fad and 56212cc00: the file-length limit, an existing test requiring every foreign key to name a delete rule (the budget-window key is now CASCADE), and a stale decile key assertion. Round 1 also noted that no role may update budget_window_runs yet, left for the work that schedules a window's later years.
  • Round 2 found no bugs; its quality findings (test coverage of every granted column, a rule's scope, two texts) are fixed in 7642bd430.
  • Round 3 found no bugs; its two text findings are fixed in 772ca6b14 and in this description.
    Each round regenerated the revision from c614b6bcccfc against the models: its operations match, apart from order and the three commented corrections.

Moved code and inline suppressions

  • The result models moved from policyengine_api/data/v2/models/reports.py to the new run_results.py, and the privilege planning and reporting code from privilege_reconciliation.py to the new privilege_plans.py, under api.quality.file-length. The code is unchanged. privilege_reconciliation re-exports the moved names, and the models package still exports every model. Helpers now used from a second module were renamed: _safe to safe_identifier, _skipped_roles_line to skipped_roles_line, _verification_report to verification_report.
  • One new inline suppression: # noqa: E402 on the models package's import of run_results, with its reason. Like the package's other imports, it must follow the naming convention the package sets before any table is declared, so it can't move to the top of the file.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.03340% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.19%. Comparing base (90b212b) to head (772ca6b).
⚠️ Report is 35 commits behind head on master.

Files with missing lines Patch % Lines
policyengine_api/data/v2/privilege_plans.py 90.07% 3 Missing and 11 partials ⚠️
...licyengine_api/data/v2/privilege_reconciliation.py 88.63% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4162      +/-   ##
==========================================
+ Coverage   92.14%   92.19%   +0.04%     
==========================================
  Files         297      298       +1     
  Lines       16673    16917     +244     
  Branches     2719     2754      +35     
==========================================
+ Hits        15364    15596     +232     
- Misses        792      798       +6     
- Partials      517      523       +6     
Flag Coverage Δ
integration 52.98% <91.64%> (+0.71%) ⬆️
unit 90.10% <88.10%> (-0.19%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

anth-volk and others added 11 commits November 4, 2026 02:24
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
anth-volk force-pushed the v2plan/b1-1-simulation-runs branch from c9c6eb6 to d674032 Compare November 3, 2026 22:39
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>
anth-volk and others added 9 commits November 5, 2026 23:37
The five revisions after c614b6bcccfc are deleted and revision 1356f658347d
is autogenerated from that revision against the same models, so the schema
at head is unchanged. Its two corrections are the ones the first of the five
had: simulation_runs.status reuses v2_simulation_status, and the downgrade
drops v2_simulation_role. The per-revision tests become one test of the
revision's source and one downgrade-and-upgrade test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each service writes only its own tables, and the foreign keys between them
stay, so PostgreSQL checks every row a simulation-side row names when it is
written. The 14 keys from simulations, reports and household jobs to the
Public API's tables are kept, with their ORM relationships, and the new
columns that name the Public API's rows get keys with the delete rules the
same columns have on simulations: a simulation run's catalog version and
dataset (RESTRICT) and region (SET NULL), and a household job's household and
catalog version (RESTRICT). reports.user_id gains an index for account
deletion's SET NULL. Revision 1356f658347d is regenerated; its corrections
are unchanged.

The integration tests now store the catalog rows and household a row names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report run compares one pair of simulation runs, so report_runs gains
baseline_simulation_run_id (required) and reform_simulation_run_id (empty for
a household report with only a baseline), each a foreign key to
simulation_runs with RESTRICT. report_run_simulation_runs and its
v2_simulation_role type are gone, and the comparison result tables' unique
keys are per report run again. create_report_run takes the simulation runs a
new run compares. Revision 1356f658347d is regenerated; its one correction
left is simulation_runs.status reusing v2_simulation_status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
report_run_comparisons is gone: a report run compares one pair, so the parts
of an economy comparison that had no result table belong to it again, typed.
report_runs gains the baseline's and reform's cliff gap and share (checked to
be set together), the SPM receipt as jsonb and the data release. The new
labor_supply_responses holds one row per report run with the earnings,
revenue and weekly hours responses, and labor_supply_decile_responses one row
per report run and income decile. The Modal worker inserts into both and the
Public API reads them. Revision 1356f658347d is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Following the simulation API's budget-window design, without its scheduling
columns: simulation_runs gains a required year, which joins the index for a
simulation's newest successful run, so a budget window compares a report's
same two simulations in every year. reports.budget_window_size (1 to 75
years) marks a budget-window report. budget_window_runs holds one row per
calculation (report, catalog version, trigger, whether to recalculate, status,
error and times), reusing the report run's status and trigger types, and
report_runs.budget_window_run_id and year name the window run and year a
report run calculates, at most one per year, kept to the same report by a
composite foreign key. The simulation service creates window runs; the Modal
worker and the Public API read them.

create_report_run now takes its package versions and simulation runs as one
ReportRunInputs, keeping it within ruff's argument limit. Three model tests
that still asserted the dropped foreign keys and index set are updated.
Revision 1356f658347d is regenerated with three corrections of one kind:
new columns reusing existing enum types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The privilege manifest can now declare a privilege on some of a table's
columns (update_columns), beside whole-table privileges. The Modal worker
updates only a run's status, Modal call ID, error, times and result columns
(a simulation run's output file, digest and household result, a report run's
cliff impact, SPM receipt and data version, a household job's result), and
the simulation service only the status, call ID, error and completion columns
of the runs and household jobs whose Modal calls it starts. PostgreSQL then
refuses any change to what a run is.

apply reads column grants from pg_attribute, grants each declared column
privilege a role lacks with one GRANT per column privilege, and reports
undeclared column grants. verify checks each declared column with
has_column_privilege and fails when a role also holds the privilege on the
whole table, which would override the column limits. The two privilege rules
and the role tests change to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plan_reconciliation takes GrantedPrivileges, the managed roles' grants on
whole tables and on columns, instead of a separate argument for each, so it
stays within ruff's argument limit without a suppression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review round and a search for stale tests:
- the decile unique key no longer includes the run pair;
- a budget-window run's year runs are deleted with it (CASCADE) instead of
  blocking its deletion, so every foreign key names a delete rule, as
  test_every_table_has_named_primary_foreign_and_relational_constraints
  requires; deleting a report still removes both;
- the run-definition columns no role may update include a report run's
  trigger and idempotency key and a household job's country and versions;
- the two revision lifecycle tests are one downgrade-and-upgrade test, and the
  module docstring describes the final schema.
Revision 1356f658347d is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 3 commits November 6, 2026 00:36
The models' reports module had 897 lines and privilege_reconciliation 810,
over api.quality.file-length's 800. Code moves unchanged:
- The result tables and their enums move from models.reports to the new
  models.run_results; reports keeps reports, budget-window runs and report
  runs. Both are imported from the models package as before. The package's
  new import line carries the same E402 suppression as its neighbours, with
  its reason, because the tables must be declared after the naming
  convention. Import Linter's model exceptions and ruff's runtime base
  classes name the new module.
- The planning and reporting half of privilege_reconciliation moves to the
  new privilege_plans, which runs no SQL; privilege_reconciliation keeps the
  database access and the command line and re-exports the moved names.
  _safe, _skipped_roles_line and _verification_report, now used from a
  second module, become safe_identifier, skipped_roles_line and
  verification_report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the second review round:
- A role test checks, for the simulation service and the Modal worker, every
  column of simulation_runs, report_runs, household_jobs and
  budget_window_runs: an UPDATE of it succeeds exactly when the manifest
  declares it for that role. Seen failing with an undeclared column grant.
- api.v2.deployed-privileges-match's scope names privilege_plans, which now
  holds the planning and verification code.
- The Alembic guide's privilege paragraph keeps apply's sentences together.
- The report runs module says complete_report_run sets markdown, which no
  managed role may update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The third review round found that the downgrade doesn't need household_jobs
empty: it drops that table's new columns with their values. It needs
simulations empty, which leaves no run or result row either. The revision's
docstring and the Alembic guide say so; no operation changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit to PolicyEngine/policyengine-sim-api that referenced this pull request Nov 6, 2026
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>
anth-volk added a commit to PolicyEngine/policyengine-sim-api that referenced this pull request Nov 6, 2026
…bles

The models describe the head of PolicyEngine/policyengine-api#4162: report
runs that name their simulation runs, budget windows, the 20 keys to the
Public API's own tables and the service's column UPDATE privileges. At the
old pin d674032a2 the comparison fails and finds none of the 20 keys. The
job's last step fails until that pull request merges into the Public API's
master with a merge commit.

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

This branch was previously deployed

1 inactive deployment
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 each run of a v2 simulation and report, and key results by run

1 participant