From 763e66fa770ff3ef600615fa5000359d3908decd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:02:09 +0400 Subject: [PATCH 001/124] Promote each staging deployment's tested entrypoint revision The main deployment's beta job and the Stage 12 comparison path's staging deployment now pass promote_entrypoint: true, as production deployments do. Each staging deployment therefore moves the staging service's stable URL to the revision it tested, runs the same stable-URL checks as production, restores the previous revision if they fail, and then deletes the staging service's older revisions except the promoted one and the one it replaced. The new rule sim.deploy.staging-promotes-tested-revision requires it: a test reads every workflow, finds each job that runs the reusable deployment for staging, and fails unless it passes promote_entrypoint: true. Its proof fixtures are the beta job before and after this change. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/simulation-deploy.yml | 5 +- .github/workflows/stage12-deploy.yml | 4 +- AGENTS.md | 2 +- README.md | 20 ++-- changelog_entry.yaml | 1 + docs/generated/rules.md | 7 +- .../DEPLOYMENT.md | 44 +++++---- .../policyengine-simulation-entry/README.md | 5 +- .../tests/test_deployment_assets.py | 91 ++++++++++++++++++- .../bad_simulation_deploy.yml | 44 +++++++++ .../good_simulation_deploy.yml | 43 +++++++++ rules/registry.toml | 25 +++++ 12 files changed, 259 insertions(+), 32 deletions(-) create mode 100644 rules/fixtures/sim.deploy.staging-promotes-tested-revision/bad_simulation_deploy.yml create mode 100644 rules/fixtures/sim.deploy.staging-promotes-tested-revision/good_simulation_deploy.yml diff --git a/.github/workflows/simulation-deploy.yml b/.github/workflows/simulation-deploy.yml index ab3d6d6f4..237962fa0 100644 --- a/.github/workflows/simulation-deploy.yml +++ b/.github/workflows/simulation-deploy.yml @@ -40,7 +40,10 @@ jobs: entrypoint_revision_prefix: s- entrypoint_min_instances: 0 entrypoint_max_instances: 2 - promote_entrypoint: false + # Staging's stable URL must serve the revision this deployment tested, + # because staging callers such as the Public API's staging service use + # that URL. Rule: sim.deploy.staging-promotes-tested-revision. + promote_entrypoint: true run_full_integration: true deploy_existing_stack: true deploy_stage12_v2: true diff --git a/.github/workflows/stage12-deploy.yml b/.github/workflows/stage12-deploy.yml index cecb6b097..35fa719e3 100644 --- a/.github/workflows/stage12-deploy.yml +++ b/.github/workflows/stage12-deploy.yml @@ -53,7 +53,9 @@ jobs: entrypoint_revision_prefix: s- entrypoint_min_instances: 0 entrypoint_max_instances: 2 - promote_entrypoint: false + # Like the main deployment, promote the tested staging revision. + # Rule: sim.deploy.staging-promotes-tested-revision. + promote_entrypoint: true # This workflow deploys and validates only the independent Stage 12 path. # The main API deployment continues to run the existing-stack suite. run_full_integration: false diff --git a/AGENTS.md b/AGENTS.md index 498175b75..1c5a1827d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Deployment pipeline -- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety` +- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.staging-promotes-tested-revision`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety` - `sim.deploy.image-smoke-covers-every-image-lib`: The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. (doc-only, not checked: follow it yourself) ### API contracts and integration diff --git a/README.md b/README.md index 3a3b8dcd8..c620c529e 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,19 @@ time: three parallel service jobs; 3. publish routing only after all three service jobs pass; 4. run generated-client and live authentication tests through the complete - three-service request path; and -5. begin the `prod` deployment only after every `beta` deployment and - qualification job succeeds; -6. assign stable production traffic to the exact tested entrypoint revision - after the complete production suite passes; and -7. verify the stable endpoint, restoring its previous revision if immediate - post-promotion checks fail. + three-service request path; +5. assign the environment's stable traffic to the exact tested entrypoint + revision after that environment's tests pass, in `beta` as in `prod`; +6. verify the stable endpoint, restoring its previous revision if immediate + post-promotion checks fail; and +7. begin the `prod` deployment only after every `beta` deployment, + qualification and promotion job succeeds. + +The staging service's stable URL therefore serves the revision that passed the +staging tests, so staging callers of that URL, such as the Public API's staging +service, reach the build the deployment tested. After each successful +promotion, the deployment deletes the service's older revisions, keeping only +the promoted revision and the one it replaced, for rollback. A successful push-triggered full-stack deployment publishes the API client from the exact deployed commit. diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 2da3c0513..c59857a31 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -10,6 +10,7 @@ - Ran Stage 12 US national simulations as 20 region-group calls each, and built an independent, immutable Stage 12 cache of three annual US datasets and 60 current-law baselines before each deployment, publishing it only after a recorded determinism check and recording its release in the v2 version manifest under a new key, while the existing key keeps a copy that earlier readers accept - Made the budget refuse a household count outside its country's plausible range (above 200 million in the US or 60 million in the UK, negative or not finite), raising ImplausibleEntityCountError instead of publishing a count like #767's changed: + - Promoted each staging deployment's tested Simulation Entrypoint revision to the staging service's stable traffic, as production deployments already do, in both the main deployment's beta job and the Stage 12 comparison path's staging deployment; the staging stable URL now serves the build that passed the staging tests, promotion runs the same stable-URL checks as production's and restores the previous revision when they fail, and the existing clean-up then deletes the staging service's older revisions except the promoted one and the one it replaced; the new rule sim.deploy.staging-promotes-tested-revision requires it - Enabled every ruff rule (select ALL), ignoring only COM812 and ISC001, which conflict with ruff format, and the D203 and D213 halves of the mutually exclusive docstring pairs; complex functions, long parameter lists and magic numbers were split into smaller helpers and grouped parameters, each behind new characterization tests, across the contract and persistence validation, entry settings, app factory and Stage 12 adapter, gateway route resolution, Stage 12 coordination, workers, parity and qualification, the precompute and national reduce, the routing publisher and its command line, the release sentinel, the package update helper and the shared rules tool; Modal function, FastAPI route, Pydantic validator and command-line signatures and every OpenAPI document are unchanged; the contract library's batch-state builder and SPM selection resolver take their route values as one grouped argument, the simulation observability initializer drops a service_version parameter no caller passed, and no PLR0913 suppression is needed - Enabled ruff's argument, idiom and pytest-style rules (ARG, ASYNC, B, C4, FBT, PD, PERF, PGH, PLW, PT, PTH, RUF, SIM); unused arguments are removed, underscored or explained, FastAPI auth dependencies use Annotated, re-raised errors keep their cause, and boolean options of in-repo helpers are keyword-only; OpenAPI documents are unchanged, and the shared rules tool's unittest tests are exempt from pytest-style rules; tests take the unused-argument rules (ARG001, ARG002, ARG005) as file-level exceptions, since their fakes and stubs mirror real signatures; best-effort telemetry and cleanup calls that swallow any error use contextlib.suppress, with the reason as a comment - Enabled ruff's structure and import rules (INP, PLC, SLF, N, A, FAST, E); long lines are wrapped, deferred imports that Modal images, test seams, import cycles or heavy dependencies need carry a reason, script directories are declared to ruff as namespace packages, four internal exceptions gained an Error suffix, and published field names, OpenAPI documents and logged error types are unchanged; tests are also exempt from SLF001, INP001 and PLC0415; heavy or optional packages that load only inside functions are listed in ruff's banned-module-level-imports, which keeps them out of module-level imports (TID253) and lets their deferred imports go without a suppression, and the Modal utility programs take INP001 file-wide diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 193ef9873..1ca667aac 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 49 | 5 | 0 | 15 | 69 | +| mechanical | 50 | 5 | 0 | 15 | 70 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 54 of 69 rules. +**Blocking:** 55 of 70 rules. ## Gaps @@ -155,6 +155,7 @@ flowchart LR | `sim.deploy.routing-publication-journaled` | Each routing publication is named by its run, attempt and environment and journaled before it is applied; an always-running finalizer resolves it from the journal with the release-gate outcome, a release reports success only after that finalizer completes, and routing jobs never tolerate a failed command. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.shell-scripts-valid` | Deployment shell scripts have valid shell syntax. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.smoke-tests-strict` | Candidate and stable smoke tests reject a revision mismatch without retrying, and fail if the previous revision does not converge. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.staging-promotes-tested-revision` | Every staging deployment passes promote_entrypoint: true, as production deployments do, so the staging service's stable URL serves the entrypoint revision that passed the staging tests. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | yes | | `sim.deploy.traffic-changes-exact` | Traffic changes target an exact, validated revision, are reversible, and refuse to run over an intervening promotion. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, and pins secret versions. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | @@ -180,6 +181,7 @@ flowchart LR r_sim_deploy_routing_publication_journaled("sim.deploy.routing-publication-journaled (enforced)") r_sim_deploy_shell_scripts_valid("sim.deploy.shell-scripts-valid (enforced)") r_sim_deploy_smoke_tests_strict("sim.deploy.smoke-tests-strict (enforced)") + r_sim_deploy_staging_promotes_tested_revision("sim.deploy.staging-promotes-tested-revision (enforced)") r_sim_deploy_traffic_changes_exact("sim.deploy.traffic-changes-exact (enforced)") r_sim_deploy_workflow_shell_safety("sim.deploy.workflow-shell-safety (enforced)") r_sim_deploy_container_context_minimal --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py @@ -198,6 +200,7 @@ flowchart LR r_sim_deploy_routing_publication_journaled --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_shell_scripts_valid --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_smoke_tests_strict --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py + r_sim_deploy_staging_promotes_tested_revision --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_traffic_changes_exact --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_workflow_shell_safety --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py ``` diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 9783ef1cc..7a7bfb591 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -33,17 +33,24 @@ qualification uses Cloud Run's generated tagged revision URL and does not require a custom domain. The `prod` environment follows the same sequence automatically, but cannot -begin until every `beta` deployment, routing, integration, and authentication -job has succeeded. Production tests also use the tagged candidate URL, so the -currently serving revision remains unchanged during qualification. +begin until every `beta` deployment, routing, integration, authentication, and +promotion job has succeeded. Both environments' tests use the tagged candidate +URL, so the currently serving revision remains unchanged during qualification. ## Promotion and rollback -After the complete production suite passes, the workflow verifies that the -exact tested revision belongs to the production service and is ready. It also -checks that stable traffic still points to the revision recorded before -deployment. If those guards pass, it assigns all stable service traffic to the -tested production revision. +Both `beta` and `prod` promote. After an environment's complete suite passes, +the workflow verifies that the exact tested revision belongs to that +environment's service and is ready. It also checks that stable traffic still +points to the revision recorded before deployment. If those checks pass, it +assigns all stable service traffic to the tested revision. + +In `beta`, promotion keeps the staging service's stable URL on the revision +that passed the staging tests, so staging callers of that URL, such as the +Public API's staging service, reach the build the deployment tested. The +staging deployment of the **Deploy Stage 12 comparison path** workflow promotes +in the same way. Rule `sim.deploy.staging-promotes-tested-revision` requires +both. The workflow then checks the stable generated Cloud Run URL. It uses the runtime revision header to prove that the stable URL reaches the exact revision @@ -51,6 +58,10 @@ that passed candidate tests, and it repeats the deployed authentication checks. If an immediate post-promotion check fails, the workflow restores the exact previous revision recorded before deployment and fails visibly. +After a successful promotion and deployment, the workflow deletes the +service's older revisions. It keeps only the promoted revision and the one it +replaced, which `simulation-rollback.yml` can route back to. + Deployment concurrency prevents two releases from changing the entrypoint service simultaneously. Promotion never targets `LATEST`. @@ -82,9 +93,9 @@ The deployment workflow passes each environment's unset or empty variable deploys `0`, which records callers that lack a route's permission but lets them through. `1` rejects those callers with 403. Any other value fails the deployment before Cloud Run changes. A new value takes effect -at the next entrypoint deployment. In `prod` that deployment is promoted to -stable traffic. `beta` never promotes its candidate revision, so a `beta` value -applies only to the tagged candidate that the deployment tests call. The route +at the next entrypoint deployment, which both `beta` and `prod` promote to +stable traffic once their tests pass. A `beta` value therefore applies to every +caller of the staging stable URL as well as to the deployment tests. The route permissions are listed in `README.md`. ### Auth0 prerequisites @@ -117,11 +128,12 @@ variable to `1`: 1. Deploy with the variable unset or `0`, and complete the Auth0 setup. 2. Watch the `prod` service for `simulation_entry_permission_missing` events with `enforced=false`. Each one names the missing permission and the - caller's `client_id`. Only `prod` serves real callers such as API v1: a - `beta` candidate receives only the deployment tests' own requests, so `beta` - events cannot show a missing API v1 grant. Access tokens issued before a - grant do not carry it, so wait at least one access-token lifetime after the - last grant with no new events. + caller's `client_id`. Only `prod` is certain to receive requests from every + real caller: the staging stable URL receives requests only from the staging + callers configured to use it, and tagged candidates only from the + deployment tests, so `beta` events cannot show every missing grant. Access + tokens issued before a grant do not carry it, so wait at least one + access-token lifetime after the last grant with no new events. 3. Set the `beta` variable to `1` and deploy. With enforcement on, the authenticated deployment tests also check that the test client's token is rejected on `GET /internal/stage12/reports/{evaluation_id}`, which requires diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 3a3f72815..272f2cbf0 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -21,8 +21,9 @@ Cloud Run's generated revision name is returned in a response header so deployment checks can prove which revision a tagged or stable URL serves. Live caller/backend authentication checks are isolated in `authenticated_tests`. -They qualify tagged Cloud Run candidates before production traffic changes and -verify the stable production endpoint immediately after promotion. +They qualify tagged Cloud Run candidates before an environment's stable traffic +changes and verify its stable endpoint immediately after promotion, in both +staging and production. ## Caller authentication and permissions diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index de151fae2..320dc145d 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -30,10 +30,20 @@ DEPLOYMENT_READY_SCRIPT = ( REPOSITORY_ROOT / ".github" / "scripts" / "simulation-deployment-ready.sh" ) +WORKFLOWS_DIRECTORY = REPOSITORY_ROOT / ".github" / "workflows" DEPLOY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.yml" REUSABLE_DEPLOY_WORKFLOW = ( REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ) +REUSABLE_DEPLOY_WORKFLOW_REFERENCE = ( + "./.github/workflows/simulation-deploy.reusable.yml" +) +STAGING_PROMOTION_FIXTURES = ( + REPOSITORY_ROOT + / "rules" + / "fixtures" + / "sim.deploy.staging-promotes-tested-revision" +) ROLLBACK_WORKFLOW = ( REPOSITORY_ROOT / ".github" / "workflows" / "simulation-rollback.yml" ) @@ -341,8 +351,8 @@ def _assert_beta_then_prod_release( assert "deployment_environment: staging" in deploy_workflow assert "deployment_environment: production" in deploy_workflow assert deploy_workflow.count("deploy_existing_stack: true") == 2 - assert "promote_entrypoint: false" in deploy_workflow - assert "promote_entrypoint: true" in deploy_workflow + assert deploy_workflow.count("promote_entrypoint: true") == 2 + assert "promote_entrypoint: false" not in deploy_workflow assert "entrypoint_environment" not in deploy_workflow assert "entrypoint_environment" not in reusable_workflow assert "entrypoint_public_url" not in deploy_workflow @@ -667,6 +677,83 @@ def test_revision_tags_fit_existing_cloud_run_service_names(): assert len(service) + len(f"{prefix}9999999") <= 46 +def _staging_deployment_job_ids(jobs: dict) -> list[str]: + """Return the jobs that run the reusable deployment workflow for staging.""" + return [ + job_id + for job_id, job in jobs.items() + if job.get("uses") == REUSABLE_DEPLOY_WORKFLOW_REFERENCE + and (job.get("with") or {}).get("deployment_environment") == "staging" + ] + + +def staging_promotion_violations(jobs: dict) -> list[str]: + """Return each staging deployment job that leaves stable traffic unchanged. + + Rule sim.deploy.staging-promotes-tested-revision: a staging deployment must + pass ``promote_entrypoint: true`` to the reusable deployment workflow, as + production deployments do. Any other value, including an expression, + counts as a violation. + """ + return [ + f"{job_id} must pass promote_entrypoint: true" + for job_id in _staging_deployment_job_ids(jobs) + if jobs[job_id]["with"].get("promote_entrypoint") is not True + ] + + +def test_staging_deployments_promote_their_tested_entrypoint_revision(): + """Staging's stable URL serves the revision that passed staging's tests. + + Callers such as the Public API's staging service use the staging stable + URL, so a staging deployment that left stable traffic on an older revision + would leave them calling a build that the deployment did not test. + """ + staging_jobs_by_workflow = {} + violations = [] + for workflow_path in sorted(WORKFLOWS_DIRECTORY.glob("*.yml")): + jobs = _workflow_jobs(workflow_path) + staging_job_ids = _staging_deployment_job_ids(jobs) + if staging_job_ids: + staging_jobs_by_workflow[workflow_path.name] = staging_job_ids + violations.extend( + f"{workflow_path.name}: {violation}" + for violation in staging_promotion_violations(jobs) + ) + + # The check must find the two known staging deployments, so that a renamed + # input or reusable workflow cannot make it pass by finding nothing. + assert "beta" in staging_jobs_by_workflow.get(DEPLOY_WORKFLOW.name, []) + assert "deploy-stage12-staging" in staging_jobs_by_workflow.get( + STAGE12_DEPLOY_WORKFLOW.name, [] + ) + for workflow_name, staging_job_ids in staging_jobs_by_workflow.items(): + jobs = _workflow_jobs(WORKFLOWS_DIRECTORY / workflow_name) + for job_id in staging_job_ids: + assert ( + jobs[job_id]["with"]["entrypoint_service"] + == "policyengine-simulation-entry-staging" + ) + assert violations == [] + + +def test_rule_proof_staging_promotes_tested_revision(): + """Proof for rule sim.deploy.staging-promotes-tested-revision. + + The bad fixture is the `beta` deployment as it was before the rule: it + tested a tagged candidate but never moved staging's stable traffic to it. + """ + bad_jobs = _workflow_jobs(STAGING_PROMOTION_FIXTURES / "bad_simulation_deploy.yml") + good_jobs = _workflow_jobs( + STAGING_PROMOTION_FIXTURES / "good_simulation_deploy.yml" + ) + + assert staging_promotion_violations(bad_jobs) == [ + "beta must pass promote_entrypoint: true" + ] + assert staging_promotion_violations(good_jobs) == [] + + def test_entrypoint_container_uses_requested_target_platform(): dockerfile = DOCKERFILE.read_text(encoding="utf-8") diff --git a/rules/fixtures/sim.deploy.staging-promotes-tested-revision/bad_simulation_deploy.yml b/rules/fixtures/sim.deploy.staging-promotes-tested-revision/bad_simulation_deploy.yml new file mode 100644 index 000000000..fbff8dc6a --- /dev/null +++ b/rules/fixtures/sim.deploy.staging-promotes-tested-revision/bad_simulation_deploy.yml @@ -0,0 +1,44 @@ +# Proof fixture: the deployment workflow's jobs as they were before the rule. +# "beta" deploys and tests a tagged staging candidate but passes +# promote_entrypoint: false, so the staging stable URL keeps serving whatever +# revision it served before, which the deployment never tested. +name: Deploy Simulation API + +on: + push: + branches: + - main + +jobs: + beta: + name: Complete beta deployment + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: beta + deployment_environment: staging + modal_environment: staging + entrypoint_service: policyengine-simulation-entry-staging + entrypoint_revision_prefix: s- + entrypoint_min_instances: 0 + entrypoint_max_instances: 2 + promote_entrypoint: false + run_full_integration: true + deploy_existing_stack: true + secrets: inherit + + prod: + name: Complete prod deployment + needs: beta + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: prod + deployment_environment: production + modal_environment: main + entrypoint_service: policyengine-simulation-entry + entrypoint_revision_prefix: p- + entrypoint_min_instances: 1 + entrypoint_max_instances: 20 + promote_entrypoint: true + run_full_integration: false + deploy_existing_stack: true + secrets: inherit diff --git a/rules/fixtures/sim.deploy.staging-promotes-tested-revision/good_simulation_deploy.yml b/rules/fixtures/sim.deploy.staging-promotes-tested-revision/good_simulation_deploy.yml new file mode 100644 index 000000000..a8f90d276 --- /dev/null +++ b/rules/fixtures/sim.deploy.staging-promotes-tested-revision/good_simulation_deploy.yml @@ -0,0 +1,43 @@ +# Proof fixture: the deployment workflow's jobs with the rule satisfied. +# "beta" promotes its tested staging candidate, as "prod" does, so the staging +# stable URL serves the revision that passed the staging tests. +name: Deploy Simulation API + +on: + push: + branches: + - main + +jobs: + beta: + name: Complete beta deployment + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: beta + deployment_environment: staging + modal_environment: staging + entrypoint_service: policyengine-simulation-entry-staging + entrypoint_revision_prefix: s- + entrypoint_min_instances: 0 + entrypoint_max_instances: 2 + promote_entrypoint: true + run_full_integration: true + deploy_existing_stack: true + secrets: inherit + + prod: + name: Complete prod deployment + needs: beta + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: prod + deployment_environment: production + modal_environment: main + entrypoint_service: policyengine-simulation-entry + entrypoint_revision_prefix: p- + entrypoint_min_instances: 1 + entrypoint_max_instances: 20 + promote_entrypoint: true + run_full_integration: false + deploy_existing_stack: true + secrets: inherit diff --git a/rules/registry.toml b/rules/registry.toml index c0308225f..d7b1e4664 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -375,6 +375,31 @@ workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[[rule]] +id = "sim.deploy.staging-promotes-tested-revision" +category = "deployment" +statement = "Every staging deployment passes promote_entrypoint: true, as production deployments do, so the staging service's stable URL serves the entrypoint revision that passed the staging tests." +rationale = "projects/policyengine-simulation-entry/DEPLOYMENT.md#promotion-and-rollback" +scope = [".github/workflows/*.yml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "projects/policyengine-simulation-entry" +nodes = [ + "tests/test_deployment_assets.py::test_staging_deployments_promote_their_tested_entrypoint_revision", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.deploy.staging-promotes-tested-revision/bad_simulation_deploy.yml" +good_fixture = "rules/fixtures/sim.deploy.staging-promotes-tested-revision/good_simulation_deploy.yml" +project = "projects/policyengine-simulation-entry" +test = "tests/test_deployment_assets.py::test_rule_proof_staging_promotes_tested_revision" + [[rule]] id = "sim.deploy.smoke-tests-strict" category = "deployment" From 2b7cde8192078eda1d22762d8747780dc9984c10 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:04:26 +0400 Subject: [PATCH 002/124] Document that a manual staging run from another branch promotes that build The beta environment does not restrict which branch a run may come from, so a manual run of either deployment workflow from a branch other than main now moves the staging stable URL to that branch's build until the next deployment from main. Co-Authored-By: Claude Opus 5.5 --- projects/policyengine-simulation-entry/DEPLOYMENT.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 7a7bfb591..c9ec55e26 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -52,6 +52,13 @@ staging deployment of the **Deploy Stage 12 comparison path** workflow promotes in the same way. Rule `sim.deploy.staging-promotes-tested-revision` requires both. +Both workflows can also be started manually, and the `beta` environment does +not restrict which branch a run may come from. A manual run from a branch other +than `main` therefore promotes that branch's build to the staging stable URL, +and every staging caller of that URL reaches the unmerged build until the next +deployment from `main` promotes again. Start a staging deployment from another +branch only when that is intended. + The workflow then checks the stable generated Cloud Run URL. It uses the runtime revision header to prove that the stable URL reaches the exact revision that passed candidate tests, and it repeats the deployed authentication checks. From e9e8de97070bb74781a428cda7554a864cb6452e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:06:18 +0400 Subject: [PATCH 003/124] Document that production waits for the staging revision clean-up The prod deployment needs the whole beta deployment, which now includes the revision clean-up that runs after a staging promotion, so a failed staging clean-up also stops the production deployment. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c620c529e..6cd9cec12 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ time: 6. verify the stable endpoint, restoring its previous revision if immediate post-promotion checks fail; and 7. begin the `prod` deployment only after every `beta` deployment, - qualification and promotion job succeeds. + qualification, promotion and revision clean-up job succeeds, so a failed + staging clean-up also stops the production deployment. The staging service's stable URL therefore serves the revision that passed the staging tests, so staging callers of that URL, such as the Public API's staging From 4f34bf0f5b88e23a62b2b225ddf30a6109c8b0d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:10:38 +0400 Subject: [PATCH 004/124] State in the entrypoint deployment guide that production waits for the staging clean-up The repository README already says the prod deployment starts only after beta's revision clean-up succeeds; the entrypoint's DEPLOYMENT.md listed the beta jobs prod waits for without the clean-up job. Co-Authored-By: Claude Opus 5.5 --- projects/policyengine-simulation-entry/DEPLOYMENT.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index c9ec55e26..fbb248291 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -33,9 +33,11 @@ qualification uses Cloud Run's generated tagged revision URL and does not require a custom domain. The `prod` environment follows the same sequence automatically, but cannot -begin until every `beta` deployment, routing, integration, authentication, and -promotion job has succeeded. Both environments' tests use the tagged candidate -URL, so the currently serving revision remains unchanged during qualification. +begin until every `beta` deployment, routing, integration, authentication, +promotion, and revision clean-up job has succeeded, so a failed staging +clean-up also stops the production deployment. Both environments' tests use +the tagged candidate URL, so the currently serving revision remains unchanged +during qualification. ## Promotion and rollback From 6b1fcf41ccbfe40cb11abf027ccb8bbbc93b56b8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:11:38 +0400 Subject: [PATCH 005/124] Add a versioned contract for household calculations on Modal The v2 household functions take one household in the Public API's situation shape, with optional axes, and one to ten policies, so a baseline and its reform are calculated in one call. The request names the PolicyEngine.py bundle it expects, whether to add missing variables (as the Public API's /calculate-full route does), and an optional SPM selection whose omission is kept distinct from an empty choice. A household keeps its caller's key order through validation and transport, at the top level and in each group, because the Public API walks a household in that order and the order of its input errors, warnings and values follows it. The result carries each policy's calculated household, warnings and SPM receipt, the applied SPM selection and the bundle provenance; a failure carries a typed error whose messages and input errors match the Public API's error responses. Co-Authored-By: Claude Opus 5.5 --- .../household_execution.py | 657 ++++++++++++++++++ .../tests/test_household_execution.py | 539 ++++++++++++++ 2 files changed, 1196 insertions(+) create mode 100644 libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py create mode 100644 libs/policyengine-simulation-contract/tests/test_household_execution.py diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py new file mode 100644 index 000000000..df1f17be9 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py @@ -0,0 +1,657 @@ +"""Versioned household calculation contract for the v2 Modal household functions. + +A household travels in the situation shape the Public API's household +calculation routes accept and return: ``people`` maps each person to their +variables, every other top-level key except ``axes`` is a group entity +collection (such as ``households`` or ``tax_units``) whose groups list their +``members``, each variable maps periods to values, and an optional ``axes`` +list varies inputs across points. A calculated household keeps that shape, +with each requested value filled in, so a v2 result can be compared field by +field with the Public API's in-process calculation. + +These models check structure, types and size only. Whether an entity +collection, variable or policy parameter exists is decided by the country +model inside the household function, which reports unknown names with the +same input errors as the Public API. + +Wire models: + +- ``HouseholdExecutionRequest``: the caller to a Modal household function. It + names one to ten policies, so a baseline and its reform are calculated in + one call. +- ``HouseholdExecutionResult`` or ``HouseholdExecutionFailure``: what the + household function returns, told apart by ``status``. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from typing import TYPE_CHECKING, Annotated, Any, Literal, Self +from uuid import UUID + +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PrivateAttr, + TypeAdapter, + field_validator, + model_serializer, + model_validator, +) + +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMProvenance, + SPMResolvedConfiguration, + SPMSelection, +) +from policyengine_simulation_contract.stage12_bundle import ( + CountryId, + Sha256Digest, + Stage12BundleManifest, +) + +if TYPE_CHECKING: + from pydantic import ModelWrapValidatorHandler, SerializerFunctionWrapHandler + +HOUSEHOLD_CONTRACT_SCHEMA_VERSION = 1 +# A v2 worker application's household function for a country is this prefix +# followed by the country ID, such as ``calculate_household_us``. +HOUSEHOLD_CALLABLE_PREFIX = "calculate_household_" +# Size limits. The variable limit leaves room for a complete calculated +# household sent back as input, which holds every variable of each entity. +MAXIMUM_PEOPLE = 1_000 +MAXIMUM_GROUPS_PER_COLLECTION = 1_000 +MAXIMUM_GROUP_COLLECTIONS = 32 +MAXIMUM_VARIABLES_PER_ENTITY = 10_000 +MAXIMUM_PERIODS_PER_VARIABLE = 1_000 +MAXIMUM_AXIS_GROUPS = 8 +MAXIMUM_PARALLEL_AXES = 32 +# The number of points a household is calculated at: the product of the point +# counts of its perpendicular axis groups. +MAXIMUM_AXIS_POINTS = 10_000 +MAXIMUM_POLICIES_PER_REQUEST = 10 +MAXIMUM_POLICY_PARAMETERS = 10_000 +MAXIMUM_WARNINGS = 10_000 + +EntityKey = Annotated[str, Field(min_length=1, max_length=255)] +EntityPlural = Annotated[str, Field(min_length=1, max_length=255)] +VariableName = Annotated[str, Field(min_length=1, max_length=255)] +PeriodKey = Annotated[str, Field(min_length=1, max_length=64)] +ParameterPath = Annotated[str, Field(min_length=1, max_length=512)] +ParameterPeriod = Annotated[str, Field(min_length=1, max_length=64)] +ContractText = Annotated[str, Field(min_length=1, max_length=255)] +PolicyKey = Annotated[ + str, + Field( + pattern=r"^[a-z][a-z0-9_-]{0,63}$", + description=( + "Caller-chosen name for one policy in the request, such as " + "'baseline' or 'reform'; its result carries the same name." + ), + ), +] + +# One input value. ``None`` asks the household function to calculate it. +InputScalar = bool | int | float | str | None +PeriodInputs = Annotated[ + dict[PeriodKey, InputScalar], + Field(max_length=MAXIMUM_PERIODS_PER_VARIABLE), +] +# A variable's values by period, or one value for the model's default period. +VariableInput = PeriodInputs | InputScalar +EntityInputs = Annotated[ + dict[VariableName, VariableInput], + Field(max_length=MAXIMUM_VARIABLES_PER_ENTITY), +] +# One calculated value. A household with axes holds one value per point. +# Infinite and undefined numbers are the strings "Infinity", "-Infinity" and +# "NaN"; the Public API writes infinities the same way. +OutputScalar = bool | int | float | str | None +OutputValue = OutputScalar | list[OutputScalar] +PeriodOutputs = Annotated[ + dict[PeriodKey, OutputValue], + Field(max_length=MAXIMUM_PERIODS_PER_VARIABLE), +] +VariableOutput = PeriodOutputs | OutputScalar +EntityOutputs = Annotated[ + dict[VariableName, VariableOutput], + Field(max_length=MAXIMUM_VARIABLES_PER_ENTITY), +] +ParameterScalar = bool | int | float | str | None +# A list value sets a parameter whose value is a list, such as a list of +# variable names. +ParameterValue = ParameterScalar | list[ParameterScalar] + +HouseholdErrorCode = Literal[ + # A household entity collection, variable, axis variable or policy + # parameter the country model does not have. ``input_errors`` lists them. + "invalid_inputs", + # The country model could not build a simulation from the household. + "invalid_household", + # The SPM selection, or the SPM configuration it needs, is unusable. + # ``spm_error`` carries the SPM error code. + "invalid_spm_selection", + # The request names a bundle other than the one this function runs. + "bundle_mismatch", + # Any other failure while calculating. + "calculation_failed", +] +InputErrorType = Literal[ + "household_entity", + "household_variable", + "household_variable_wrong_entity", + "household_axis_variable", + "policy_parameter", +] + + +class StrictHouseholdModel(BaseModel): + """Base for household contracts; rejects unknown fields and non-finite numbers.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, frozen=True) + + +class CallerOrderedModel(BaseModel): + """A model that serializes its top-level keys in the order its caller gave them. + + The Public API walks a household in the order the caller sent it, so the + order of unrecognized inputs, warnings and calculated values depends on + it. Pydantic itself would write the declared fields before the extra ones. + Keys the caller did not give follow, in Pydantic's order. + """ + + model_config = ConfigDict(extra="allow", allow_inf_nan=False, frozen=True) + + _caller_key_order: tuple[str, ...] = PrivateAttr(default=()) + + @model_validator(mode="wrap") + @classmethod + def remember_caller_key_order( + cls, data: object, handler: ModelWrapValidatorHandler[Self] + ) -> Self: + """Record the order of the caller's top-level keys for serialization.""" + validated: Self = handler(data) + if isinstance(data, Mapping): + validated._caller_key_order = tuple(str(key) for key in data) + elif isinstance(data, CallerOrderedModel): + validated._caller_key_order = data.caller_key_order + return validated + + @model_serializer(mode="wrap") + def serialize_in_caller_order( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Return the caller's keys in the caller's order, then any others.""" + dumped: dict[str, Any] = handler(self) + ordered = {key: dumped[key] for key in self._caller_key_order if key in dumped} + ordered.update(dumped) + left_out = self.keys_left_out() + return {key: value for key, value in ordered.items() if key not in left_out} + + @property + def caller_key_order(self) -> tuple[str, ...]: + """The caller's top-level keys, in the order the caller gave them.""" + return self._caller_key_order + + def keys_left_out(self) -> frozenset[str]: + """Return the keys serialization leaves out; none unless a subclass says.""" + return frozenset() + + +def _require_unique_members(members: tuple[str, ...]) -> tuple[str, ...]: + if len(set(members)) != len(members): + msg = "group members must be unique" + raise ValueError(msg) + return members + + +GroupMembers = Annotated[ + tuple[EntityKey, ...], + Field(min_length=1, max_length=MAXIMUM_PEOPLE), + AfterValidator(_require_unique_members), +] + + +class HouseholdGroup(CallerOrderedModel): + """One group entity instance, such as a household or a tax unit. + + ``members`` names the people in the group. Every other key is a variable + mapped to its values by period, exactly as in the Public API's situation + shape. + """ + + members: GroupMembers + __pydantic_extra__: dict[VariableName, VariableInput] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + @model_validator(mode="after") + def limit_variables(self) -> Self: + """Bound the number of variables one group may carry.""" + if len(self.model_extra or {}) > MAXIMUM_VARIABLES_PER_ENTITY: + msg = "a group carries too many variables" + raise ValueError(msg) + return self + + +class CalculatedHouseholdGroup(CallerOrderedModel): + """One calculated group entity instance: its members and calculated variables.""" + + members: GroupMembers + __pydantic_extra__: dict[VariableName, VariableOutput] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + @model_validator(mode="after") + def limit_variables(self) -> Self: + """Bound the number of variables one group may carry.""" + if len(self.model_extra or {}) > MAXIMUM_VARIABLES_PER_ENTITY: + msg = "a group carries too many variables" + raise ValueError(msg) + return self + + +class HouseholdAxis(StrictHouseholdModel): + """One input varied across points, as policyengine-core reads an axis. + + The variable ``name`` takes ``count`` evenly spaced values from ``min`` to + ``max`` in ``period`` (the model's default period when omitted), for the + entity member at position ``index`` (the first when omitted). + """ + + name: VariableName + count: Annotated[int, Field(ge=1, le=MAXIMUM_AXIS_POINTS)] + min: int | float + max: int | float + period: PeriodKey | int | None = None + index: Annotated[int, Field(ge=0)] | None = None + + @model_serializer(mode="wrap") + def serialize_axis(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + """Leave out the optional keys the caller did not give.""" + dumped: dict[str, Any] = handler(self) + return { + name: value + for name, value in dumped.items() + if name not in {"period", "index"} or name in self.model_fields_set + } + + +AxisGroup = Annotated[ + tuple[HouseholdAxis, ...], + Field(min_length=1, max_length=MAXIMUM_PARALLEL_AXES), +] + + +def _axis_point_count(axes: tuple[tuple[HouseholdAxis, ...], ...] | None) -> int: + if not axes: + return 1 + return math.prod(group[0].count for group in axes) + + +def _validated_axes( + axes: tuple[tuple[HouseholdAxis, ...], ...] | None, +) -> tuple[tuple[HouseholdAxis, ...], ...] | None: + if axes is None: + return None + for group in axes: + if len({axis.count for axis in group}) != 1: + msg = "parallel axes in one group must share one point count" + raise ValueError(msg) + if _axis_point_count(axes) > MAXIMUM_AXIS_POINTS: + msg = ( + "a household may be calculated at no more than " + f"{MAXIMUM_AXIS_POINTS} axis points" + ) + raise ValueError(msg) + return axes + + +Axes = Annotated[ + tuple[AxisGroup, ...] | None, + Field(max_length=MAXIMUM_AXIS_GROUPS), + AfterValidator(_validated_axes), +] + + +# One group entity collection: each group of one entity, by the group's name. +InputGroups = Annotated[ + dict[EntityKey, HouseholdGroup], Field(max_length=MAXIMUM_GROUPS_PER_COLLECTION) +] +CalculatedGroups = Annotated[ + dict[EntityKey, CalculatedHouseholdGroup], + Field(max_length=MAXIMUM_GROUPS_PER_COLLECTION), +] + + +class _Situation(CallerOrderedModel): + """A situation-shaped household: ``people``, group collections and ``axes``.""" + + axes: Axes = None + + @model_validator(mode="after") + def limit_collections(self) -> Self: + """Bound the number of group collections a household may carry.""" + if len(self.model_extra or {}) > MAXIMUM_GROUP_COLLECTIONS: + msg = "a household carries too many group collections" + raise ValueError(msg) + return self + + def keys_left_out(self) -> frozenset[str]: + """Leave out ``axes`` when the household has none.""" + return frozenset({"axes"}) if self.axes is None else frozenset() + + @property + def has_axes(self) -> bool: + """Whether the household is calculated at more than one set of inputs.""" + return self.axes is not None + + def axis_point_count(self) -> int: + """Return how many points the household is calculated at (1 without axes).""" + return _axis_point_count(self.axes) + + def to_situation(self) -> dict[str, Any]: + """Return the household as the JSON situation a country model reads.""" + return self.model_dump(mode="json") + + +class HouseholdDocument(_Situation): + """A household to calculate, in the Public API's situation shape. + + ``people`` maps each person's name to their variables. Every other key + except ``axes`` is a group entity collection, by its plural, mapping each + group's name to a ``HouseholdGroup``. A variable maps periods (such as + ``"2026"``) to values; ``None`` asks for the value to be calculated. + """ + + people: Annotated[ + dict[EntityKey, EntityInputs], Field(min_length=1, max_length=MAXIMUM_PEOPLE) + ] + __pydantic_extra__: dict[EntityPlural, InputGroups] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + +class CalculatedHousehold(_Situation): + """A calculated household, in the shape the Public API returns as ``result``. + + It holds the request's people, groups and axes with each requested value + filled in. With axes, a calculated variable holds one value per point. + """ + + people: Annotated[ + dict[EntityKey, EntityOutputs], Field(min_length=1, max_length=MAXIMUM_PEOPLE) + ] + __pydantic_extra__: dict[EntityPlural, CalculatedGroups] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + +class HouseholdPolicy(StrictHouseholdModel): + """One policy to calculate the household under. + + ``parameters`` maps each changed parameter path to its values by period + range (``"YYYY-MM-DD.YYYY-MM-DD"``), as in the Public API's policy JSON. + Empty parameters mean current law. + """ + + policy_key: PolicyKey + parameters: Annotated[ + dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], + Field(max_length=MAXIMUM_POLICY_PARAMETERS), + ] + + +def _unique_policy_keys( + policies: tuple[HouseholdPolicy, ...], +) -> tuple[HouseholdPolicy, ...]: + keys = [policy.policy_key for policy in policies] + if len(set(keys)) != len(keys): + msg = "policy keys must be unique within a request" + raise ValueError(msg) + return policies + + +Policies = Annotated[ + tuple[HouseholdPolicy, ...], + Field(min_length=1, max_length=MAXIMUM_POLICIES_PER_REQUEST), + AfterValidator(_unique_policy_keys), +] + + +def _reject_explicit_null_spm(value: object) -> object: + # Validators do not run on the default, so only an explicit null gets here. + if value is None: + msg = "spm must be an object; omit it to inherit the certified defaults." + raise ValueError(msg) + return value + + +SPMChoice = Annotated[ + SPMSelection | None, + BeforeValidator(_reject_explicit_null_spm), +] + + +class HouseholdBundleProvenance(StrictHouseholdModel): + """The PolicyEngine.py bundle a household calculation runs with. + + Names the PolicyEngine.py and policyengine-core versions, the country model + package and version, and the SHA-256 of the normalized bundle manifest. + """ + + policyengine_version: ContractText + core_package_version: ContractText + country_package_name: ContractText + country_package_version: ContractText + bundle_manifest_sha256: Sha256Digest + + @classmethod + def from_bundle( + cls, + bundle: Stage12BundleManifest, + *, + bundle_manifest_sha256: str, + country: str, + ) -> Self: + """Return ``country``'s provenance within a normalized bundle manifest. + + Raises ValueError when the bundle does not carry ``country``. + """ + country_bundle = next( + (item for item in bundle.countries if item.country == country), None + ) + if country_bundle is None: + msg = f"the bundle does not carry country {country!r}" + raise ValueError(msg) + return cls( + policyengine_version=bundle.policyengine_version, + core_package_version=bundle.core_package_version, + country_package_name=country_bundle.country_package_name, + country_package_version=country_bundle.country_package_version, + bundle_manifest_sha256=bundle_manifest_sha256, + ) + + +def household_callable_name(country: str) -> str: + """Return the name of a v2 worker application's household function.""" + return f"{HOUSEHOLD_CALLABLE_PREFIX}{country}" + + +class HouseholdExecutionRequest(StrictHouseholdModel): + """A caller to a Modal household function: calculate one household. + + ``calculation_id`` is the caller's durable identity for the calculation. + ``bundle`` is the bundle the caller resolved from the version manifest; the + household function refuses a request for any other bundle. + + ``add_missing_variables`` adds every yearly, monthly and eternal variable + the household's entities lack before calculating, as the Public API's + ``/calculate-full`` route does. ``spm`` selects SPM settings for a US + household: omit it to inherit the bundle's certified defaults; supplying + it, even as an empty object, is a choice, and then a missing SPM input is + an error rather than an uncalculated value. ``spm_selection_source`` says + whether the selection came from this request or from a saved household, + which changes only how an SPM forecast the bundle no longer carries is + reported. + + To calculate a saved household as the Public API's stored-household + route (``GET /{country}/household/{id}/policy/{policy_id}``) does, set + ``add_missing_variables``, because that route always adds missing + variables; pass the household's saved SPM selection, if it has one, as + ``spm``, so it counts as chosen; and set ``spm_selection_source`` to + ``"saved_household"``. + """ + + schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION + calculation_id: UUID + country: CountryId + household: HouseholdDocument + policies: Policies + add_missing_variables: bool = False + spm: SPMChoice = None + spm_selection_source: Literal["request", "saved_household"] = "request" + bundle: HouseholdBundleProvenance + + @model_serializer(mode="wrap") + def serialize_request( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Leave out ``spm`` when no selection was made, so omission is kept.""" + dumped: dict[str, Any] = handler(self) + if self.spm is None: + dumped.pop("spm", None) + return dumped + + @property + def spm_requested(self) -> bool: + """Whether the caller chose SPM settings rather than inheriting the defaults.""" + return self.spm is not None + + +class HouseholdPolicyResult(StrictHouseholdModel): + """The calculated household under one policy. + + ``warnings`` lists, in order, the deprecated inputs that were ignored and, + for a household with axes, the values that could not be calculated, worded + as the Public API words them. ``spm_provenance`` is the SPM calculation + receipt of a US household calculated with SPM settings. + """ + + policy_key: PolicyKey + household: CalculatedHousehold + warnings: Annotated[tuple[str, ...], Field(max_length=MAXIMUM_WARNINGS)] = () + spm_provenance: SPMProvenance | None = None + + +class HouseholdCalculationOutput(StrictHouseholdModel): + """A finished household calculation: one result per policy, in request order. + + ``spm_config`` is the complete SPM selection the calculation applied: the + caller's choice merged over the bundle's certified defaults, or None when + no SPM applies (any country but the US). + """ + + country: CountryId + bundle: HouseholdBundleProvenance + spm_config: SPMResolvedConfiguration | None = None + results: Annotated[ + tuple[HouseholdPolicyResult, ...], + Field(min_length=1, max_length=MAXIMUM_POLICIES_PER_REQUEST), + ] + + @field_validator("results") + @classmethod + def require_unique_policy_keys( + cls, results: tuple[HouseholdPolicyResult, ...] + ) -> tuple[HouseholdPolicyResult, ...]: + """Reject results that repeat a policy key.""" + keys = [result.policy_key for result in results] + if len(set(keys)) != len(keys): + msg = "result policy keys must be unique" + raise ValueError(msg) + return results + + +class HouseholdInputError(StrictHouseholdModel): + """One household or policy name the country model does not recognize. + + Field names and messages match the entries of the Public API's ``errors`` + list, which name entity plurals only for a variable placed on the wrong + entity. + """ + + type: InputErrorType + name: str + path: str + message: str + expected_entity_plural: str | None = None + actual_entity_plural: str | None = None + + @model_serializer(mode="wrap") + def serialize_input_error( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Leave out unset entity plurals, as the Public API's error entries do.""" + dumped: dict[str, Any] = handler(self) + for name in ("expected_entity_plural", "actual_entity_plural"): + if dumped.get(name) is None: + dumped.pop(name, None) + return dumped + + +class HouseholdCalculationError(StrictHouseholdModel): + """Why a household calculation failed. + + ``message`` is worded as the Public API words the matching error response. + ``input_errors`` lists unrecognized names for ``invalid_inputs``; + ``spm_error`` holds the SPM error code for ``invalid_spm_selection``; + ``policy_key`` names the policy being prepared or calculated, when the + failure belongs to one. + """ + + code: HouseholdErrorCode + message: Annotated[str, Field(min_length=1)] + input_errors: tuple[HouseholdInputError, ...] = () + spm_error: SPMErrorDetail | None = None + policy_key: PolicyKey | None = None + + +class HouseholdWorkerIdentity(StrictHouseholdModel): + """The deployed Modal application and function that ran the calculation.""" + + application_name: ContractText + function_name: ContractText + + +class HouseholdExecutionResult(StrictHouseholdModel): + """A Modal household function to its caller: the calculation succeeded.""" + + status: Literal["succeeded"] = "succeeded" + schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION + calculation_id: UUID + output: HouseholdCalculationOutput + worker: HouseholdWorkerIdentity + + +class HouseholdExecutionFailure(StrictHouseholdModel): + """A Modal household function to its caller: the calculation failed.""" + + status: Literal["failed"] = "failed" + schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION + calculation_id: UUID + error: HouseholdCalculationError + worker: HouseholdWorkerIdentity + + +HouseholdExecutionOutcome = Annotated[ + HouseholdExecutionResult | HouseholdExecutionFailure, + Field(discriminator="status"), +] +_EXECUTION_OUTCOME_ADAPTER: TypeAdapter[ + HouseholdExecutionResult | HouseholdExecutionFailure +] = TypeAdapter(HouseholdExecutionOutcome) + + +def parse_household_execution_outcome( + payload: object, +) -> HouseholdExecutionResult | HouseholdExecutionFailure: + """Validate a household function's return value as a success or a failure.""" + return _EXECUTION_OUTCOME_ADAPTER.validate_python(payload) diff --git a/libs/policyengine-simulation-contract/tests/test_household_execution.py b/libs/policyengine-simulation-contract/tests/test_household_execution.py new file mode 100644 index 000000000..3d1ef4f2c --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_household_execution.py @@ -0,0 +1,539 @@ +"""Tests for the versioned household calculation contract.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import pytest +from policyengine_simulation_contract.household_execution import ( + MAXIMUM_AXIS_POINTS, + MAXIMUM_POLICIES_PER_REQUEST, + CalculatedHousehold, + HouseholdBundleProvenance, + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdDocument, + HouseholdExecutionFailure, + HouseholdExecutionRequest, + HouseholdExecutionResult, + HouseholdInputError, + HouseholdPolicy, + HouseholdPolicyResult, + HouseholdWorkerIdentity, + household_callable_name, + parse_household_execution_outcome, +) +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMResolvedConfiguration, + SPMRuntimeVersions, + SPMSelection, + build_spm_provenance, +) +from policyengine_simulation_contract.stage12_bundle import ( + Stage12BundleManifest, + Stage12CountryBundle, + Stage12Dataset, +) +from pydantic import ValidationError + +CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") + +# A household in the situation shape the Public API's calculate routes accept. +US_SITUATION = { + "people": { + "you": {"age": {"2026": 40}, "employment_income": {"2026": 50_000}}, + "your partner": {"age": {"2026": 38}}, + }, + "households": { + "your household": { + "members": ["you", "your partner"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + } + }, + "tax_units": {"your tax unit": {"members": ["you", "your partner"]}}, + "axes": [ + [ + { + "name": "employment_income", + "count": 11, + "min": 0, + "max": 100_000, + "period": "2026", + } + ] + ], +} +# The same household with its top-level keys, and one group's keys, in +# another order. +REORDERED_SITUATION = { + "households": { + "your household": { + "state_name": {"2026": "CA"}, + "members": ["you", "your partner"], + "household_net_income": {"2026": None}, + } + }, + "axes": US_SITUATION["axes"], + "people": US_SITUATION["people"], + "tax_units": US_SITUATION["tax_units"], +} +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 3_000} +} +SPM_RUNTIME_VERSIONS = SPMRuntimeVersions.model_validate( + { + "policyengine": "0.0.0-test-policyengine", + "policyengine-core": "0.0.0-test-policyengine-core", + "policyengine-us": "0.0.0-test-policyengine-us", + "spm-calculator": "0.0.0-test-spm-calculator", + } +) +RESOLVED_SPM_SELECTION = SPMSelection( + forecast_content_sha256="c" * 64, + scenario="ce_trend", + geography_kind="national", + geography_id=None, + county_vintage="2020", + as_of=None, +) + + +def _bundle() -> HouseholdBundleProvenance: + return HouseholdBundleProvenance( + policyengine_version="6.7.5", + core_package_version="3.33.0", + country_package_name="policyengine-us", + country_package_version="2.2.1", + bundle_manifest_sha256="a" * 64, + ) + + +def _policies() -> list[dict[str, object]]: + return [ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": CTC_REFORM}, + ] + + +def _request(**overrides: object) -> HouseholdExecutionRequest: + values: dict[str, object] = { + "calculation_id": str(CALCULATION_ID), + "country": "us", + "household": US_SITUATION, + "policies": _policies(), + "bundle": _bundle().model_dump(mode="json"), + } + values.update(overrides) + return HouseholdExecutionRequest.model_validate(values) + + +def _worker() -> HouseholdWorkerIdentity: + return HouseholdWorkerIdentity( + application_name="policyengine-simulation-v2-py6-7-5", + function_name="calculate_household_us", + ) + + +def _calculated(situation: dict) -> CalculatedHousehold: + return CalculatedHousehold.model_validate(situation) + + +def _output(**overrides: object) -> HouseholdCalculationOutput: + values: dict[str, object] = { + "country": "us", + "bundle": _bundle(), + "results": ( + HouseholdPolicyResult( + policy_key="baseline", + household=_calculated( + { + "people": {"you": {"age": {"2026": 40}}}, + "households": {"your household": {"members": ["you"]}}, + } + ), + warnings=("Input `x` was ignored.",), + ), + ), + } + values.update(overrides) + return HouseholdCalculationOutput.model_validate(values) + + +def test_household_document_keeps_the_public_api_situation_shape() -> None: + document = HouseholdDocument.model_validate(US_SITUATION) + + assert document.to_situation() == US_SITUATION + assert document.has_axes + assert document.axis_point_count() == 11 + + +def test_households_keep_the_callers_key_order_through_transport() -> None: + # The Public API walks a household in the order its caller sent it, so the + # order of unrecognized inputs, warnings and calculated values follows it. + request = _request(household=REORDERED_SITUATION) + transported = HouseholdExecutionRequest.model_validate( + json.loads(request.model_dump_json()) + ) + + for situation in ( + request.household.to_situation(), + transported.household.to_situation(), + json.loads(transported.household.model_dump_json()), + _calculated(REORDERED_SITUATION).to_situation(), + ): + assert list(situation) == list(REORDERED_SITUATION) + assert list(situation["households"]["your household"]) == [ + "state_name", + "members", + "household_net_income", + ] + assert situation == REORDERED_SITUATION + + +def test_a_household_without_axes_never_gains_an_axes_key() -> None: + situation = {key: value for key, value in US_SITUATION.items() if key != "axes"} + + document = HouseholdDocument.model_validate(situation) + + assert not document.has_axes + assert document.axis_point_count() == 1 + assert document.to_situation() == situation + assert "axes" not in json.loads(document.model_dump_json()) + + +def test_axes_keep_the_values_and_keys_the_caller_sent() -> None: + situation = { + "people": {"you": {}}, + "axes": [[{"name": "employment_income", "count": 3, "min": 0, "max": 2.5}]], + } + + assert HouseholdDocument.model_validate(situation).to_situation() == situation + + +def test_a_household_accepts_values_without_periods() -> None: + situation = { + "people": {"you": {"age": 40, "is_blind": False}}, + "households": {"your household": {"members": ["you"]}}, + } + + assert HouseholdDocument.model_validate(situation).to_situation() == situation + + +@pytest.mark.parametrize( + "situation", + [ + {"households": {"your household": {"members": ["you"]}}}, + {"people": {}}, + {"people": {"you": {}}, "households": {"your household": {}}}, + {"people": {"you": {}}, "households": {"your household": {"members": []}}}, + { + "people": {"you": {}}, + "households": {"your household": {"members": ["you", "you"]}}, + }, + {"people": {"you": {"age": {"2026": float("inf")}}}}, + {"people": {"you": {"age": {"2026": float("nan")}}}}, + {"people": {"you": {"age": {"2026": [40, 41]}}}}, + {"people": {"you": {}}, "axes": [[]]}, + {"people": {"you": {}}, "axes": [{"name": "age", "count": 2}]}, + {"people": {"you": {}}, "axes": [[{"name": "age", "count": 0}]]}, + ], +) +def test_a_household_rejects_malformed_situations(situation: dict) -> None: + with pytest.raises(ValidationError): + HouseholdDocument.model_validate(situation) + + +def test_axes_are_bounded_by_their_total_point_count() -> None: + axis = {"name": "employment_income", "min": 0, "max": 1, "period": "2026"} + within = { + "people": {"you": {}}, + "axes": [[{**axis, "count": 100}], [{**axis, "count": 100}]], + } + beyond = { + "people": {"you": {}}, + "axes": [[{**axis, "count": MAXIMUM_AXIS_POINTS}], [{**axis, "count": 2}]], + } + + assert HouseholdDocument.model_validate(within).axis_point_count() == 10_000 + with pytest.raises(ValidationError, match="axis points"): + HouseholdDocument.model_validate(beyond) + + +def test_parallel_axes_must_share_one_point_count() -> None: + axis = {"name": "employment_income", "min": 0, "max": 1} + + with pytest.raises(ValidationError, match="parallel axes"): + HouseholdDocument.model_validate( + { + "people": {"you": {}, "partner": {}}, + "axes": [[{**axis, "count": 3}, {**axis, "count": 4, "index": 1}]], + } + ) + + +def test_one_request_carries_the_baseline_and_its_reform() -> None: + request = _request() + + assert [policy.policy_key for policy in request.policies] == ["baseline", "reform"] + assert request.policies[1].parameters == CTC_REFORM + assert request.schema_version == 1 + assert request.add_missing_variables is False + assert request.spm_selection_source == "request" + assert request.bundle == _bundle() + + +def test_add_missing_variables_asks_for_a_full_calculation() -> None: + assert _request(add_missing_variables=True).add_missing_variables is True + + +def test_a_request_needs_one_to_ten_policies_with_unique_keys() -> None: + eleven_policies = [ + {"policy_key": f"policy-{index}", "parameters": {}} + for index in range(MAXIMUM_POLICIES_PER_REQUEST + 1) + ] + repeated_key = [ + {"policy_key": "reform", "parameters": {}}, + {"policy_key": "reform", "parameters": CTC_REFORM}, + ] + + assert len(_request(policies=eleven_policies[:10]).policies) == 10 + with pytest.raises(ValidationError): + _request(policies=[]) + with pytest.raises(ValidationError): + _request(policies=eleven_policies) + with pytest.raises(ValidationError, match="unique"): + _request(policies=repeated_key) + with pytest.raises(ValidationError): + _request(policies=[{"policy_key": "Not A Key", "parameters": {}}]) + + +def test_omitted_spm_inherits_the_defaults_and_is_not_a_choice() -> None: + request = _request() + + assert request.spm is None + assert request.spm_requested is False + dumped = request.model_dump(mode="json") + assert "spm" not in dumped + assert HouseholdExecutionRequest.model_validate(dumped).spm_requested is False + + +def test_an_empty_spm_selection_is_still_a_choice() -> None: + request = _request(spm={}, spm_selection_source="saved_household") + + assert request.spm_requested is True + dumped = request.model_dump(mode="json") + assert dumped["spm"] == {} + assert dumped["spm_selection_source"] == "saved_household" + assert HouseholdExecutionRequest.model_validate(dumped).spm_requested is True + + +def test_an_explicit_null_spm_is_rejected() -> None: + with pytest.raises(ValidationError, match="omit it"): + _request(spm=None) + + +def test_a_request_rejects_unknown_fields_and_other_schema_versions() -> None: + with pytest.raises(ValidationError): + _request(unexpected=True) + with pytest.raises(ValidationError): + _request(schema_version=2) + with pytest.raises(ValidationError): + _request(country="ca") + + +def test_a_calculated_household_keeps_axis_results_and_non_finite_strings() -> None: + calculated = { + "people": { + "you": { + "age": {"2026": [40, 40]}, + "marginal_tax_rate": {"2026": ["Infinity", 0.25]}, + "is_blind": {"2026": [False, False]}, + } + }, + "households": { + "your household": { + "members": ["you"], + "state_name": {"2026": ["CA", "CA"]}, + "spm_unit_net_income": {"2026": [None, None]}, + } + }, + "axes": [[{"name": "employment_income", "count": 2, "min": 0, "max": 1}]], + } + + assert _calculated(calculated).to_situation() == calculated + + +def test_execution_outcomes_are_told_apart_by_status() -> None: + succeeded = HouseholdExecutionResult( + calculation_id=CALCULATION_ID, output=_output(), worker=_worker() + ) + failed = HouseholdExecutionFailure( + calculation_id=CALCULATION_ID, + error=HouseholdCalculationError( + code="invalid_spm_selection", + message="SPM settings are only available for the US", + spm_error=SPMErrorDetail( + code="SPM_SETTINGS_UNSUPPORTED", + message="SPM settings are only available for the US", + ), + ), + worker=_worker(), + ) + + parsed_success = parse_household_execution_outcome( + succeeded.model_dump(mode="json") + ) + parsed_failure = parse_household_execution_outcome(failed.model_dump(mode="json")) + + assert isinstance(parsed_success, HouseholdExecutionResult) + assert parsed_success == succeeded + assert parsed_success.schema_version == 1 + assert isinstance(parsed_failure, HouseholdExecutionFailure) + assert parsed_failure == failed + with pytest.raises(ValidationError): + parse_household_execution_outcome({"status": "running"}) + + +def test_input_errors_serialize_as_the_public_api_lists_them() -> None: + wrong_entity = HouseholdInputError( + type="household_variable_wrong_entity", + name="state_name", + path="household.people.you.state_name", + message="Household variable `state_name` belongs on `households` ...", + expected_entity_plural="households", + actual_entity_plural="people", + ) + unknown = HouseholdInputError( + type="household_variable", + name="not_a_variable", + path="household.people.you.not_a_variable", + message="Unrecognized household variable ...", + ) + + assert wrong_entity.model_dump(mode="json") == { + "type": "household_variable_wrong_entity", + "name": "state_name", + "path": "household.people.you.state_name", + "message": "Household variable `state_name` belongs on `households` ...", + "expected_entity_plural": "households", + "actual_entity_plural": "people", + } + # Like the Public API's error entries, an error names entity plurals only + # for a variable on the wrong entity. + assert unknown.model_dump(mode="json") == { + "type": "household_variable", + "name": "not_a_variable", + "path": "household.people.you.not_a_variable", + "message": "Unrecognized household variable ...", + } + assert HouseholdInputError.model_validate(unknown.model_dump()) == unknown + + +def test_output_results_must_have_unique_policy_keys() -> None: + result = _output().results[0] + + with pytest.raises(ValidationError, match="unique"): + _output(results=(result, result)) + + +def test_output_carries_the_applied_spm_selection_and_each_receipt() -> None: + receipt = build_spm_provenance( + forecast_id="forecast-2026", + forecast_sha256="c" * 64, + selection=RESOLVED_SPM_SELECTION, + years=("2026",), + runtime_versions=SPM_RUNTIME_VERSIONS, + ) + applied = SPMResolvedConfiguration.model_validate( + RESOLVED_SPM_SELECTION.model_dump(mode="json") + ) + result = _output().results[0].model_copy(update={"spm_provenance": receipt}) + + output = _output(results=(result,), spm_config=applied) + transported = HouseholdCalculationOutput.model_validate( + json.loads(output.model_dump_json()) + ) + + assert transported.spm_config == applied + assert transported.results[0].spm_provenance == receipt + assert transported.model_dump(mode="json")["spm_config"] == { + "forecast_content_sha256": "c" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + assert _output().spm_config is None + + +def test_bundle_provenance_is_read_from_a_normalized_bundle() -> None: + dataset = Stage12Dataset( + identity="populace_uk_2023", + uri="hf://policyengine/data/populace_uk_2023.h5@r1", + artifact_revision="r1", + sha256="c" * 64, + repo_type="dataset", + ) + bundle = Stage12BundleManifest( + policyengine_version="6.7.5", + policyengine_requirement="policyengine==6.7.5", + core_package_version="3.33.0", + core_package_requirement="policyengine-core==3.33.0", + countries=( + Stage12CountryBundle( + country="uk", + country_package_name="policyengine-uk", + country_package_version="2.130.1", + country_package_requirement="policyengine-uk==2.130.1", + data_package_name="populace-data", + data_package_version="0.1.0", + data_release_version="r1", + data_artifact_revision="r1", + default_dataset="populace_uk_2023", + default_dataset_uri=dataset.uri, + datasets=(dataset,), + ), + ), + ) + + provenance = HouseholdBundleProvenance.from_bundle( + bundle, bundle_manifest_sha256="d" * 64, country="uk" + ) + + assert provenance.model_dump() == { + "policyengine_version": "6.7.5", + "core_package_version": "3.33.0", + "country_package_name": "policyengine-uk", + "country_package_version": "2.130.1", + "bundle_manifest_sha256": "d" * 64, + } + with pytest.raises(ValueError, match="does not carry"): + HouseholdBundleProvenance.from_bundle( + bundle, bundle_manifest_sha256="d" * 64, country="us" + ) + + +def test_household_functions_are_named_by_country() -> None: + assert household_callable_name("us") == "calculate_household_us" + assert household_callable_name("uk") == "calculate_household_uk" + + +def test_policy_values_may_be_lists_for_list_parameters() -> None: + policy = HouseholdPolicy.model_validate( + { + "policy_key": "reform", + "parameters": { + "gov.usda.snap.income.sources.earned": { + "2026-01-01.2100-12-31": ["employment_income"] + } + }, + } + ) + + assert policy.parameters["gov.usda.snap.income.sources.earned"] == { + "2026-01-01.2100-12-31": ["employment_income"] + } From 03cda9bc00139d5bce679aba30b39efadab22b02 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:21:27 +0400 Subject: [PATCH 006/124] Record each country's capabilities in v2 manifest schema version 2 Schema version 2 of the v2 version manifest records, for each country a worker application serves, whether it is economy-capable (it names a single-simulation worker) and which function calculates its households, and the validation receipt names an invocation for each of those functions. The Simulation Entrypoint's comparison adapter now accepts only a country the manifest marks as economy-capable, so the report coordinator never receives a report for a country that only calculates households. Deployed and rollback revisions read schema version 1 and reject unknown fields, so the publisher writes schema version 2 under a new key and keeps writing the schema version 1 forms (with region datasets, without them, and without cache releases) under the existing keys whenever schema version 1 can describe the manifest. Current readers use the schema version 2 document while the older keys hold its schema version 1 forms, or, for a manifest schema version 1 cannot describe, unless the older keys hold a later validation; otherwise they read the older keys as before, as economy-capable countries without household functions. Code released before schema version 2 that publishes after a rollback therefore leaves no stale document behind, and the next publish starts from what it wrote. The publisher records every bundle country as economy-capable. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 49 ++- .../stage12_manifest.py | 293 ++++++++++++--- .../tests/test_stage12_manifest.py | 336 +++++++++++++++--- .../stage12_adapter.py | 20 +- .../tests/stage12_fixtures.py | 23 +- .../tests/test_stage12_adapter.py | 11 + .../tests/test_stage12_backend.py | 57 ++- .../modal/utils/update_v2_version_manifest.py | 8 +- .../tests/test_update_v2_version_manifest.py | 17 + 9 files changed, 686 insertions(+), 128 deletions(-) diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index 953ca0d70..0a2eddcbd 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -396,20 +396,38 @@ and coordinator images do not contain these files. Deployment validation runs one real 2026 region-group baseline and requires it to load the precomputed file without recalculation. -Readers released before cache releases reject fields they do not know, and +Manifest schema version 2 records, for each country a worker application +serves, whether it is economy-capable (it has a single-simulation worker, so +its reports may be given to the report coordinator) and which function +calculates its households. The Simulation Entrypoint's comparison adapter +skips any country the manifest does not mark as economy-capable, with the +reason `unsupported_country`, so the coordinator receives only those +countries' reports. + +Readers released before a schema change reject fields they do not know, and the manifest is published before the Cloud Run revision that reads it is -promoted. The publisher therefore writes the whole manifest under the -`active-with-region-datasets` key, the same manifest without each country -bundle's `region_datasets` under the `active-with-cache` key, and without cache -releases either under the `active` key; readers released before each field keep -reading the key that lacks it. Current readers use the -`active-with-region-datasets` copy only while it, with its region datasets -removed, equals the manifest under `active-with-cache`; otherwise they read -`active-with-cache`, falling back to `active` before the first publish that -writes it. Code released before region datasets republishes only the two older -keys, so after a rollback the out-of-date region dataset copy stops matching and -is no longer read. Removing the older copies needs a later change, once no -deployed or rollback revision reads them. +promoted. The publisher therefore writes the whole schema version 2 manifest +under the `active-schema-2` key and, in schema version 1 form (without the +capability and household fields), the same manifest under the +`active-with-region-datasets` key, without each country bundle's +`region_datasets` under the `active-with-cache` key, and without cache releases +either under the `active` key; readers released before each field keep reading +the key that lacks it. A schema version 1 document marks every listed country +economy-capable, with no household function. + +Current readers use the `active-schema-2` document while each older key holds +its schema version 1 form. Schema version 1 cannot describe a country that is +not economy-capable, so a manifest holding one is written only under +`active-schema-2` and the older keys keep the last manifest written there; such +a document is used unless the document under the older keys was validated +later. Otherwise readers use the `active-with-region-datasets` copy only while +it, with its region datasets removed, equals the manifest under +`active-with-cache`; otherwise they read `active-with-cache`, falling back to +`active` before the first publish that writes it. Code released before schema +version 2 or before region datasets republishes only the older keys it knows, so +after a rollback the out-of-date copies stop matching and are no longer read, +and the next publish starts from what the older code wrote. Removing the older +copies needs a later change, once no deployed or rollback revision reads them. A rollback to code released before region datasets has one window: the older worker is deployed before its publish job runs, and until that publish the @@ -417,8 +435,9 @@ region dataset copy still matches, so updated entry services keep sending regional provenance for state and district runs, which the older worker refuses. Only Stage 12 comparison runs fail in that window, never production responses; if the older deploy's validation fails, the window lasts until a later publish. -To avoid it, delete the `active-with-region-datasets` key from the -`simulation-api-v2-version-manifest` Modal Dict before deploying the older code. +To avoid it, delete the `active-schema-2` and `active-with-region-datasets` keys +from the `simulation-api-v2-version-manifest` Modal Dict before deploying the +older code. `region_datasets` names the dataset each region type loads. The entry service records that dataset as a state or district run's dataset provenance, and the diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py index a10d414cc..4cb9562b6 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py @@ -1,4 +1,12 @@ -"""Strict, separately stored Stage 12 v2 worker version manifest.""" +"""Strict, separately stored Stage 12 v2 worker version manifest. + +Schema version 2 records, for each country a worker application serves, +whether it can run economy reports and which function calculates its +households. It is stored under its own key. Whenever schema version 1 can +describe the same manifest, it is also written in that form, without those +fields, under the keys that readers released before schema version 2 read: +with region datasets, without them, and without them or cache releases. +""" from __future__ import annotations @@ -8,7 +16,7 @@ from copy import deepcopy from datetime import datetime from hashlib import sha256 -from typing import Annotated, Literal, Protocol +from typing import TYPE_CHECKING, Annotated, Literal, Protocol from packaging.version import InvalidVersion, Version from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -19,16 +27,24 @@ Stage12BundleManifest, ) +if TYPE_CHECKING: + from pydantic.main import IncEx + V1_ROUTING_STATE_NAME = "simulation-api-routing-state" V2_VERSION_MANIFEST_NAME = "simulation-api-v2-version-manifest" -# The complete manifest, including each country bundle's region datasets. +# The schema version 2 manifest. Readers released with schema version 2 read +# this key first. +SCHEMA_2_MANIFEST_KEY = "active-schema-2" +# Readers released before schema version 2 forbid unknown fields, so they read +# the keys below, which hold the same manifest in schema version 1 form. This +# one keeps each country bundle's region datasets. REGION_DATASETS_MANIFEST_KEY = "active-with-region-datasets" # Readers released before region datasets forbid unknown fields, so they keep -# reading this key, which holds the same manifest without them. +# reading this key, which holds the schema version 1 form without them. ACTIVE_MANIFEST_KEY = "active-with-cache" # Readers released before Stage 12 cache releases forbid unknown fields, so -# they keep reading this key, which holds the same manifest without them or -# region datasets. +# they keep reading this key, which holds the schema version 1 form without +# the cache fields or region datasets. PRE_CACHE_MANIFEST_KEY = "active" V2_APPLICATION_PREFIX = "policyengine-simulation-v2-py" V1_APPLICATION_PREFIX = "policyengine-simulation-py" @@ -46,10 +62,35 @@ class StrictManifestModel(BaseModel): class V2CountryWorker(StrictManifestModel): - """A country served by a v2 worker, and the callable that runs its simulations.""" + """A country served by a v2 worker application, and what it can calculate. + + An economy-capable country names the callable that runs one of its report + simulations; only such a country's reports are given to the report + coordinator. ``household_callable`` names the function that calculates + its households, and is None when the application has none or it was not + validated. + """ country: CountryId - single_simulation_callable: CallableName + economy_capable: bool + single_simulation_callable: CallableName | None = None + household_callable: CallableName | None = None + + @model_validator(mode="after") + def validate_capabilities(self) -> V2CountryWorker: + """Require a simulation callable exactly when the country is economy-capable. + + A country must also have at least one capability. + """ + if self.economy_capable != (self.single_simulation_callable is not None): + msg = ( + "an economy-capable country, and only one, names a simulation callable" + ) + raise ValueError(msg) + if not self.economy_capable and self.household_callable is None: + msg = "a country needs at least one capability" + raise ValueError(msg) + return self class Stage12CacheRelease(StrictManifestModel): @@ -71,7 +112,8 @@ class V2WorkerValidation(StrictManifestModel): """Receipt for a successful validation of a deployed v2 worker application. Names the application and bundle digest that were validated, when, and the - validation invocation IDs overall and for each country. + validation invocation IDs: overall, for each economy-capable country's + simulation worker, and for each country's household function. """ validated: Literal[True] @@ -80,14 +122,36 @@ class V2WorkerValidation(StrictManifestModel): validated_at: datetime validation_invocation_id: ManifestText country_validation_invocation_ids: dict[CountryId, ManifestText] + household_validation_invocation_ids: dict[CountryId, ManifestText] = Field( + default_factory=dict + ) cache: Stage12CacheRelease | None = None +def _require_function_receipts( + countries: tuple[V2CountryWorker, ...], validation: V2WorkerValidation +) -> None: + """Require a validation invocation for exactly the functions countries name.""" + economy_countries = { + worker.country for worker in countries if worker.economy_capable + } + if set(validation.country_validation_invocation_ids) != economy_countries: + msg = "worker validation does not cover exactly the economy-capable countries" + raise ValueError(msg) + household_countries = { + worker.country for worker in countries if worker.household_callable is not None + } + if set(validation.household_validation_invocation_ids) != household_countries: + msg = "worker validation does not cover exactly the household functions" + raise ValueError(msg) + + class V2WorkerVersion(StrictManifestModel): """One deployed v2 worker application and the bundle it runs. Lists its report coordinator callable, its per-country workers and its - validation receipt; the countries must match the bundle's, in order. + validation receipt; the countries must match the bundle's, in order, and + every function a country names must have been validated. """ application_name: ManifestText @@ -105,7 +169,7 @@ def validate_worker(self) -> V2WorkerVersion: The application name must be the v2 name derived from the bundle's PolicyEngine.py version, the validation must name this application and bundle digest, and the countries must equal the bundle's, in order, - each with a validation invocation. + each named function with a validation invocation. """ if not self.application_name.startswith(V2_APPLICATION_PREFIX): msg = "v2 application uses an invalid name prefix" @@ -134,11 +198,31 @@ def validate_worker(self) -> V2WorkerVersion: if countries != bundle_countries: msg = "v2 worker countries do not match the normalized bundle" raise ValueError(msg) - if set(self.validation.country_validation_invocation_ids) != set(countries): - msg = "worker validation does not cover every country" - raise ValueError(msg) + _require_function_receipts(self.countries, self.validation) return self + def economy_worker(self, country: str) -> V2CountryWorker | None: + """Return ``country``'s worker when it can run economy reports.""" + return next( + ( + worker + for worker in self.countries + if worker.country == country and worker.economy_capable + ), + None, + ) + + def household_worker(self, country: str) -> V2CountryWorker | None: + """Return ``country``'s worker when it has a household function.""" + return next( + ( + worker + for worker in self.countries + if worker.country == country and worker.household_callable is not None + ), + None, + ) + class V2VersionManifest(StrictManifestModel): """Registry of deployed v2 worker versions, keyed by PolicyEngine.py version. @@ -147,7 +231,7 @@ class V2VersionManifest(StrictManifestModel): ``generation`` identifies the publish that produced this manifest. """ - schema_version: Literal[1] = 1 + schema_version: Literal[2] = 2 generation: ManifestText default_version: ManifestText versions: dict[str, V2WorkerVersion] @@ -224,37 +308,106 @@ def _is_newer(candidate: str, current: str | None) -> bool: return False -_REGION_DATASETS_FIELD = { - "bundle": {"countries": {"__all__": {"region_datasets"}}}, -} +def schema_1_manifest_payload( + manifest: V2VersionManifest, + *, + include_cache: bool = True, + include_region_datasets: bool = True, +) -> dict[str, object] | None: + """Return the manifest as readers released before schema version 2 accept it. + + Without ``include_region_datasets`` each country bundle's region datasets + are left out, for readers released before them; without ``include_cache`` + the cache fields are left out too, for readers released before cache + releases. Returns None when a country is not economy-capable, which + schema version 1 cannot describe. + """ + if any( + not country.economy_capable + for worker in manifest.versions.values() + for country in worker.countries + ): + return None + validation_exclude = {"household_validation_invocation_ids"} + worker_exclude: dict[str, IncEx | bool] = { + "countries": {"__all__": {"economy_capable", "household_callable"}}, + "validation": validation_exclude, + } + if not include_region_datasets: + worker_exclude["bundle"] = {"countries": {"__all__": {"region_datasets"}}} + if not include_cache: + worker_exclude["cache"] = True + validation_exclude.add("cache") + payload = manifest.model_dump( + mode="json", + exclude={"versions": {"__all__": worker_exclude}}, + ) + payload["schema_version"] = 1 + return payload def pre_region_datasets_manifest_payload( manifest: V2VersionManifest, -) -> dict[str, object]: +) -> dict[str, object] | None: """Return the manifest as readers released before region datasets accept it.""" - return manifest.model_dump( - mode="json", - exclude={"versions": {"__all__": _REGION_DATASETS_FIELD}}, - ) + return schema_1_manifest_payload(manifest, include_region_datasets=False) -def pre_cache_manifest_payload(manifest: V2VersionManifest) -> dict[str, object]: +def pre_cache_manifest_payload(manifest: V2VersionManifest) -> dict[str, object] | None: """Return the manifest as readers released before cache releases accept it.""" - return manifest.model_dump( - mode="json", - exclude={ - "versions": { - "__all__": { - "cache": True, - "validation": {"cache"}, - **_REGION_DATASETS_FIELD, - } - } - }, + return schema_1_manifest_payload( + manifest, include_cache=False, include_region_datasets=False ) +def _schema_1_copies( + manifest: V2VersionManifest, +) -> dict[str, dict[str, object]] | None: + """Return the schema version 1 documents a publish writes, by key. + + Returns None when schema version 1 cannot describe the manifest. + """ + complete = schema_1_manifest_payload(manifest) + without_region_datasets = pre_region_datasets_manifest_payload(manifest) + without_cache = pre_cache_manifest_payload(manifest) + if complete is None or without_region_datasets is None or without_cache is None: + return None + return { + REGION_DATASETS_MANIFEST_KEY: complete, + ACTIVE_MANIFEST_KEY: without_region_datasets, + PRE_CACHE_MANIFEST_KEY: without_cache, + } + + +def _upgraded_schema_1_document(document: Mapping[str, object]) -> dict[str, object]: + """Return a schema version 1 document in schema version 2 form. + + Schema version 1 listed only countries whose report simulations a worker + runs, and no household functions, so every country is economy-capable. + """ + upgraded = deepcopy(dict(document)) + upgraded["schema_version"] = 2 + versions = upgraded.get("versions") + if isinstance(versions, dict): + for worker in versions.values(): + countries = worker.get("countries") if isinstance(worker, dict) else None + if isinstance(countries, list): + for country in countries: + if isinstance(country, dict): + country.setdefault("economy_capable", True) + return upgraded + + +def parse_v2_manifest(value: object) -> V2VersionManifest: + """Validate a stored manifest, reading a schema version 1 document as version 2. + + Raises ValueError (as a pydantic ValidationError) for an invalid document. + """ + if isinstance(value, Mapping) and value.get("schema_version") == 1: + value = _upgraded_schema_1_document(value) + return V2VersionManifest.model_validate(value) + + def _is_current_region_dataset_copy(value: object, cache_copy: object) -> bool: """Return whether ``value`` is the copy the last publish wrote with ``cache_copy``. @@ -268,13 +421,14 @@ def _is_current_region_dataset_copy(value: object, cache_copy: object) -> bool: if cache_copy is None: return True try: - manifest = V2VersionManifest.model_validate(value) + manifest = parse_v2_manifest(value) except (TypeError, ValueError): return False return pre_region_datasets_manifest_payload(manifest) == cache_copy -def _current_manifest(store: V2ManifestStore) -> object: +def _current_schema_1_manifest(store: V2ManifestStore) -> object: + """Return the schema version 1 document the last publish wrote.""" # The newest key the last publish wrote: each older key holds the same # manifest without the fields its readers do not accept. cache_copy = store.get(ACTIVE_MANIFEST_KEY) @@ -287,6 +441,53 @@ def _current_manifest(store: V2ManifestStore) -> object: return store.get(PRE_CACHE_MANIFEST_KEY) +def _newest_validation(manifest: V2VersionManifest) -> datetime: + return max(worker.validation.validated_at for worker in manifest.versions.values()) + + +def _is_current_schema_2_document(store: V2ManifestStore, document: object) -> bool: + """Return whether ``document``, under the schema version 2 key, is the latest. + + Code released before schema version 2 publishes only the older keys, for + example after a rollback, which leaves an out-of-date schema version 2 + document behind. Whenever schema version 1 can describe a manifest, its + publish writes the schema version 1 forms to the older keys, so the + document is current while each older key holds its form. Otherwise (a + country that is not economy-capable) the publish left the older keys + alone, and the document is current unless the document under them was + validated later. An unreadable document counts as current, so readers and + publishers report it. + """ + try: + manifest = parse_v2_manifest(document) + except (TypeError, ValueError): + return True + schema_1_copies = _schema_1_copies(manifest) + if schema_1_copies is not None: + return all( + store.get(key) in (None, payload) + for key, payload in schema_1_copies.items() + ) + older = _current_schema_1_manifest(store) + if older is None: + return True + try: + older_manifest = parse_v2_manifest(older) + except (TypeError, ValueError): + return True + return _newest_validation(older_manifest) <= _newest_validation(manifest) + + +def _current_manifest(store: V2ManifestStore) -> object: + # Until the first schema version 2 publish, only the older keys exist. + schema_2_document = store.get(SCHEMA_2_MANIFEST_KEY) + if schema_2_document is not None and _is_current_schema_2_document( + store, schema_2_document + ): + return schema_2_document + return _current_schema_1_manifest(store) + + def build_next_v2_manifest( *, current: object, @@ -303,7 +504,7 @@ def build_next_v2_manifest( versions: dict[str, V2WorkerVersion] = {} previous_default = None else: - parsed = V2VersionManifest.model_validate(current) + parsed = parse_v2_manifest(current) versions = deepcopy(parsed.versions) previous_default = parsed.default_version version = worker.bundle.policyengine_version @@ -330,8 +531,10 @@ def publish_v2_manifest( ) -> V2VersionManifest: """Add ``worker`` to the stored manifest, write it back and return it. - The whole manifest is written as one store value, so readers never see a - partial update. + The whole manifest is written as one store value per key, so readers never + see a partial update. The schema version 1 keys are rewritten only when + schema version 1 can describe the manifest; otherwise their readers keep + the last manifest written there. """ next_manifest = build_next_v2_manifest( current=_current_manifest(store), @@ -341,9 +544,11 @@ def publish_v2_manifest( # Each key holds one entire validated document, and a reader uses one # whole document (the region dataset copy only while it matches the cache # copy), so no reader observes a partial update assembled from several. - store[REGION_DATASETS_MANIFEST_KEY] = next_manifest.model_dump(mode="json") - store[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(next_manifest) - store[PRE_CACHE_MANIFEST_KEY] = pre_cache_manifest_payload(next_manifest) + store[SCHEMA_2_MANIFEST_KEY] = next_manifest.model_dump(mode="json") + schema_1_copies = _schema_1_copies(next_manifest) + if schema_1_copies is not None: + for key, payload in schema_1_copies.items(): + store[key] = payload return next_manifest @@ -363,7 +568,7 @@ def load(self) -> V2VersionManifest: """ value = _current_manifest(self._store) try: - manifest = V2VersionManifest.model_validate(value) + manifest = parse_v2_manifest(value) except (TypeError, ValueError): if self._last_valid is None: msg = "no valid v2 version manifest is available" diff --git a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py index 78c1f908e..cd7bccfe7 100644 --- a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py +++ b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py @@ -17,6 +17,7 @@ ACTIVE_MANIFEST_KEY, PRE_CACHE_MANIFEST_KEY, REGION_DATASETS_MANIFEST_KEY, + SCHEMA_2_MANIFEST_KEY, V1_ROUTING_STATE_NAME, V2_VERSION_MANIFEST_NAME, Stage12CacheRelease, @@ -26,9 +27,11 @@ V2WorkerVersion, assert_separate_manifest_names, manifest_digest, + parse_v2_manifest, pre_cache_manifest_payload, pre_region_datasets_manifest_payload, publish_v2_manifest, + schema_1_manifest_payload, v2_application_name, validate_manifest_has_no_credentials, ) @@ -90,22 +93,39 @@ def _resolved_bundle(version: str = "5.2.0") -> ResolvedStage12Bundle: ) -def _worker(version: str = "5.2.0") -> V2WorkerVersion: +def _country_worker( + country: str, *, economy: bool = True, household: bool = False +) -> V2CountryWorker: + return V2CountryWorker( + country=country, + economy_capable=economy, + single_simulation_callable=( + f"run_single_simulation_{country}" if economy else None + ), + household_callable=f"calculate_household_{country}" if household else None, + ) + + +def _worker( + version: str = "5.2.0", + *, + with_household_functions: bool = False, + household_only: tuple[str, ...] = (), +) -> V2WorkerVersion: resolved = _resolved_bundle(version) application_name = v2_application_name(version) + countries = tuple( + _country_worker( + country, + economy=country not in household_only, + household=with_household_functions or country in household_only, + ) + for country in ("us", "uk") + ) return V2WorkerVersion( application_name=application_name, report_coordinator_callable="coordinate_report", - countries=( - V2CountryWorker( - country="us", - single_simulation_callable="run_single_simulation_us", - ), - V2CountryWorker( - country="uk", - single_simulation_callable="run_single_simulation_uk", - ), - ), + countries=countries, bundle=resolved.bundle, bundle_manifest_sha256=resolved.bundle_manifest_sha256, validation=V2WorkerValidation( @@ -115,8 +135,14 @@ def _worker(version: str = "5.2.0") -> V2WorkerVersion: validated_at=datetime(2026, 9, 14, tzinfo=UTC), validation_invocation_id="validation-123", country_validation_invocation_ids={ - "us": "validation-us-123", - "uk": "validation-uk-123", + worker.country: f"validation-{worker.country}-123" + for worker in countries + if worker.economy_capable + }, + household_validation_invocation_ids={ + worker.country: f"household-validation-{worker.country}-123" + for worker in countries + if worker.household_callable is not None }, ), ) @@ -127,8 +153,10 @@ def test_v2_application_name_cannot_collide_with_v1() -> None: assert not v2_application_name("5.2.0").startswith("policyengine-simulation-py") -def _cached_worker(version: str = "5.2.0") -> V2WorkerVersion: - worker = _worker(version) +def _cached_worker( + version: str = "5.2.0", *, with_household_functions: bool = False +) -> V2WorkerVersion: + worker = _worker(version, with_household_functions=with_household_functions) cache = Stage12CacheRelease( manifest_sha256="c" * 64, partition_sha256="d" * 64, @@ -163,9 +191,11 @@ def test_v2_application_name_rejects_versions_a_modal_name_cannot_hold( v2_application_name(version) -def _regional_worker(version: str = "5.2.0") -> V2WorkerVersion: +def _regional_worker( + version: str = "5.2.0", *, with_household_functions: bool = False +) -> V2WorkerVersion: """Return a cached worker whose US bundle names each region type's dataset.""" - worker = _cached_worker(version) + worker = _cached_worker(version, with_household_functions=with_household_functions) countries = tuple( country.model_copy( update={ @@ -191,31 +221,82 @@ def _regional_worker(version: str = "5.2.0") -> V2WorkerVersion: def test_publish_writes_one_complete_v2_document_per_key() -> None: store = FakeStore() - manifest = publish_v2_manifest(store=store, worker=_regional_worker()) + manifest = publish_v2_manifest( + store=store, worker=_regional_worker(with_household_functions=True) + ) assert store.writes == [ - (REGION_DATASETS_MANIFEST_KEY, manifest.model_dump(mode="json")), + (SCHEMA_2_MANIFEST_KEY, manifest.model_dump(mode="json")), + (REGION_DATASETS_MANIFEST_KEY, schema_1_manifest_payload(manifest)), (ACTIVE_MANIFEST_KEY, pre_region_datasets_manifest_payload(manifest)), (PRE_CACHE_MANIFEST_KEY, pre_cache_manifest_payload(manifest)), ] + assert manifest.schema_version == 2 assert manifest.default_version == "5.2.0" assert manifest.versions["5.2.0"].validation.validated is True - assert V2ManifestLoader(store).load().versions["5.2.0"].cache is not None + loaded = V2ManifestLoader(store).load() + assert loaded == manifest + assert loaded.versions["5.2.0"].cache is not None + assert loaded.versions["5.2.0"].household_worker("us") is not None + + +def _assert_schema_1_top_level(payload: object) -> dict: + # The fields of the schema version 1 manifest and country models. Readers + # released before schema version 2 forbid any other field, so adding one + # here would stop them loading. + assert isinstance(payload, dict) + assert payload["schema_version"] == 1 + assert set(payload) == { + "default_version", + "generation", + "schema_version", + "versions", + } + for country in payload["versions"]["5.2.0"]["countries"]: + assert set(country) == {"country", "single_simulation_callable"} + return payload + + +@pytest.mark.parametrize("key", [REGION_DATASETS_MANIFEST_KEY, ACTIVE_MANIFEST_KEY]) +def test_readers_released_before_schema_2_get_only_fields_they_accept( + key: str, +) -> None: + store = FakeStore() + publish_v2_manifest( + store=store, worker=_regional_worker(with_household_functions=True) + ) + + with_cache = _assert_schema_1_top_level(store.values[key]) + worker = with_cache["versions"]["5.2.0"] + assert set(worker) == { + "application_name", + "bundle", + "bundle_manifest_sha256", + "cache", + "countries", + "report_coordinator_callable", + "validation", + } + assert set(worker["validation"]) == { + "application_name", + "bundle_manifest_sha256", + "cache", + "country_validation_invocation_ids", + "validated", + "validated_at", + "validation_invocation_id", + } def test_readers_released_before_the_cache_get_only_fields_they_accept() -> None: store = FakeStore() - publish_v2_manifest(store=store, worker=_cached_worker()) + publish_v2_manifest( + store=store, worker=_cached_worker(with_household_functions=True) + ) # The fields of each model before Stage 12 cache releases. Those readers # forbid any other field, so adding one here would stop them loading. - pre_cache = store.values[PRE_CACHE_MANIFEST_KEY] - assert set(pre_cache) == { - "default_version", - "generation", - "schema_version", - "versions", - } + pre_cache = _assert_schema_1_top_level(store.values[PRE_CACHE_MANIFEST_KEY]) worker = pre_cache["versions"]["5.2.0"] assert set(worker) == { "application_name", @@ -264,16 +345,18 @@ def test_readers_released_before_region_datasets_get_only_fields_they_accept( assert set(country) == _COUNTRY_BUNDLE_FIELDS_BEFORE_REGION_DATASETS -def test_the_region_dataset_key_keeps_region_datasets_and_cache() -> None: +@pytest.mark.parametrize("key", [SCHEMA_2_MANIFEST_KEY, REGION_DATASETS_MANIFEST_KEY]) +def test_the_region_dataset_keys_keep_region_datasets_and_cache(key: str) -> None: store = FakeStore() publish_v2_manifest(store=store, worker=_regional_worker()) - loaded = V2ManifestLoader(store).load().versions["5.2.0"] + loaded = parse_v2_manifest(store.values[key]).versions["5.2.0"] us = next(country for country in loaded.bundle.countries if country.country == "us") assert us.dataset_for_region_type("state") == "populace_us_2024_local" assert us.dataset_for_region_type("national") == "populace_us_2024" assert us.dataset_for_region_type("congressional_district") is None assert loaded.cache is not None + assert V2ManifestLoader(store).load().versions["5.2.0"] == loaded def test_a_bundle_published_before_region_datasets_has_none() -> None: @@ -304,35 +387,111 @@ def test_first_publish_with_region_datasets_keeps_versions_from_the_cache_key() assert V2ManifestLoader(store).load() == manifest -def test_first_publish_with_cache_keeps_versions_from_the_pre_cache_key() -> None: - earlier = _worker("5.1.0").model_dump( - mode="json", exclude={"cache": True, "validation": {"cache"}} +def _schema_1_document(*, include_cache: bool, version: str = "5.1.0") -> dict: + """Return a manifest exactly as a publisher released before schema 2 wrote it.""" + worker = _worker(version).model_dump( + mode="json", + exclude={ + "countries": {"__all__": {"economy_capable", "household_callable"}}, + "validation": ( + {"household_validation_invocation_ids"} + | (set() if include_cache else {"cache"}) + ), + **({} if include_cache else {"cache": True}), + }, ) - store = FakeStore( - { - PRE_CACHE_MANIFEST_KEY: { - "schema_version": 1, - "generation": "5.1.0:earlier", - "default_version": "5.1.0", - "versions": {"5.1.0": earlier}, - } - } + return { + "schema_version": 1, + "generation": f"{version}:earlier", + "default_version": version, + "versions": {version: worker}, + } + + +@pytest.mark.parametrize( + ("key", "include_cache"), + [(ACTIVE_MANIFEST_KEY, True), (PRE_CACHE_MANIFEST_KEY, False)], +) +def test_loader_reads_a_schema_1_document_as_economy_capable_countries( + *, key: str, include_cache: bool +) -> None: + store = FakeStore({key: _schema_1_document(include_cache=include_cache)}) + + manifest = V2ManifestLoader(store).load() + + assert manifest.schema_version == 2 + worker = manifest.versions["5.1.0"] + assert [country.economy_capable for country in worker.countries] == [True, True] + assert worker.economy_worker("uk") == _country_worker("uk") + assert worker.household_worker("us") is None + assert worker.validation.household_validation_invocation_ids == {} + + +def test_loader_reads_the_schema_2_document_its_publish_wrote() -> None: + store = FakeStore({ACTIVE_MANIFEST_KEY: _schema_1_document(include_cache=True)}) + newer = publish_v2_manifest( + store=store, worker=_worker(with_household_functions=True) ) + + loaded = V2ManifestLoader(store).load() + + # Only the schema version 2 document names the household functions. + assert loaded == newer + assert loaded.versions["5.2.0"].household_worker("us") is not None + + +@pytest.mark.parametrize( + ("key", "include_cache"), + [(ACTIVE_MANIFEST_KEY, True), (PRE_CACHE_MANIFEST_KEY, False)], +) +def test_first_schema_2_publish_keeps_versions_from_a_schema_1_key( + *, key: str, include_cache: bool +) -> None: + store = FakeStore({key: _schema_1_document(include_cache=include_cache)}) assert V2ManifestLoader(store).load().default_version == "5.1.0" - manifest = publish_v2_manifest(store=store, worker=_cached_worker()) + manifest = publish_v2_manifest( + store=store, worker=_cached_worker(with_household_functions=True) + ) assert sorted(manifest.versions) == ["5.1.0", "5.2.0"] + assert manifest.versions["5.1.0"].household_worker("us") is None + assert manifest.versions["5.2.0"].household_worker("us") is not None assert V2ManifestLoader(store).load() == manifest +def test_a_household_only_country_leaves_schema_1_readers_on_their_manifest() -> None: + earlier = _schema_1_document(include_cache=True) + store = FakeStore({ACTIVE_MANIFEST_KEY: earlier}) + + manifest = publish_v2_manifest(store=store, worker=_worker(household_only=("uk",))) + + # Schema version 1 cannot describe a country without a single-simulation + # function, so only the schema version 2 document changes. + assert [key for key, _ in store.writes] == [SCHEMA_2_MANIFEST_KEY] + assert store.values[ACTIVE_MANIFEST_KEY] == earlier + assert schema_1_manifest_payload(manifest, include_cache=True) is None + worker = manifest.versions["5.2.0"] + assert worker.economy_worker("uk") is None + assert worker.household_worker("uk") is not None + assert worker.economy_worker("us") is not None + + +def test_parse_v2_manifest_rejects_unknown_schema_versions() -> None: + document = _schema_1_document(include_cache=True) + document["schema_version"] = 3 + + with pytest.raises(ValueError, match="schema_version"): + parse_v2_manifest(document) + + def test_loader_retains_its_last_valid_v2_document() -> None: store = FakeStore() expected = publish_v2_manifest(store=store, worker=_worker()) loader = V2ManifestLoader(store) assert loader.load() == expected - store.values[ACTIVE_MANIFEST_KEY] = {"schema_version": 999} + store.values[SCHEMA_2_MANIFEST_KEY] = {"schema_version": 999} assert loader.load() == expected @@ -363,7 +522,7 @@ def test_v1_and_v2_publication_and_removal_are_bidirectionally_isolated() -> Non manifest ) - v2_store.values.pop(ACTIVE_MANIFEST_KEY) + v2_store.values.pop(SCHEMA_2_MANIFEST_KEY) assert v1_store.values == {ACTIVE_MANIFEST_KEY: {"v1": "new"}} @@ -398,6 +557,46 @@ def test_cache_metadata_is_optional_for_old_workers_and_must_match_validation() V2WorkerVersion.model_validate(value) +@pytest.mark.parametrize( + ("economy_capable", "single_simulation_callable", "household_callable"), + [ + (True, None, None), + (False, "run_single_simulation_us", None), + (False, "run_single_simulation_us", "calculate_household_us"), + (False, None, None), + ], +) +def test_country_capabilities_must_name_their_functions( + *, + economy_capable: bool, + single_simulation_callable: str | None, + household_callable: str | None, +) -> None: + with pytest.raises(ValueError, match=r"economy-capable|capability"): + V2CountryWorker( + country="us", + economy_capable=economy_capable, + single_simulation_callable=single_simulation_callable, + household_callable=household_callable, + ) + + +def test_household_functions_need_a_validation_receipt() -> None: + value = _worker(with_household_functions=True).model_dump(mode="json") + value["validation"]["household_validation_invocation_ids"] = {"us": "only-us"} + + with pytest.raises(ValueError, match="household"): + V2WorkerVersion.model_validate(value) + + +def test_only_economy_capable_countries_have_simulation_receipts() -> None: + value = _worker(household_only=("uk",)).model_dump(mode="json") + value["validation"]["country_validation_invocation_ids"]["uk"] = "not-needed" + + with pytest.raises(ValueError, match="economy-capable"): + V2WorkerVersion.model_validate(value) + + def test_manifest_rejects_credential_like_fields() -> None: with pytest.raises(ValueError, match="credential-like"): validate_manifest_has_no_credentials( @@ -479,3 +678,50 @@ def test_a_publish_after_a_rollback_keeps_the_versions_the_older_code_added() -> manifest = publish_v2_manifest(store=store, worker=_regional_worker("5.4.0")) assert sorted(manifest.versions) == ["5.3.0", "5.4.0"] + + +def test_a_schema_2_document_left_behind_by_an_older_publish_is_not_read() -> None: + """After a rollback, code released before schema 2 writes only older keys.""" + store = FakeStore() + publish_v2_manifest( + store=store, worker=_regional_worker(with_household_functions=True) + ) + stale = store.values[SCHEMA_2_MANIFEST_KEY] + older = publish_v2_manifest(store=FakeStore(), worker=_regional_worker("5.3.0")) + store.values[REGION_DATASETS_MANIFEST_KEY] = schema_1_manifest_payload(older) + store.values[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(older) + store.values[PRE_CACHE_MANIFEST_KEY] = pre_cache_manifest_payload(older) + + loaded = V2ManifestLoader(store).load() + manifest = publish_v2_manifest(store=store, worker=_regional_worker("5.4.0")) + + assert loaded.default_version == "5.3.0" + assert loaded.versions["5.3.0"].household_worker("us") is None + assert sorted(manifest.versions) == ["5.3.0", "5.4.0"] + assert store.values[SCHEMA_2_MANIFEST_KEY] != stale + + +def _validated_on(worker: V2WorkerVersion, day: int) -> V2WorkerVersion: + """Return ``worker`` as validated on ``day`` of September 2026.""" + validation = worker.validation.model_copy( + update={"validated_at": datetime(2026, 9, day, tzinfo=UTC)} + ) + return worker.model_copy(update={"validation": validation}) + + +def test_a_household_only_manifest_is_read_until_an_older_publish_follows() -> None: + # Schema version 1 cannot describe a household-only country, so its + # publish leaves the older keys alone; a later validation under them + # means code released before schema 2 published after it. + store = FakeStore({ACTIVE_MANIFEST_KEY: _schema_1_document(include_cache=True)}) + household_only = publish_v2_manifest( + store=store, worker=_validated_on(_worker(household_only=("uk",)), 15) + ) + assert V2ManifestLoader(store).load() == household_only + + older = publish_v2_manifest( + store=FakeStore(), worker=_validated_on(_cached_worker("5.3.0"), 20) + ) + store.values[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(older) + + assert V2ManifestLoader(store).load().default_version == "5.3.0" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py index c66929968..24abe5ff8 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py @@ -26,10 +26,7 @@ Stage12CountryBundle, Stage12Dataset, ) - from policyengine_simulation_contract.stage12_manifest import ( - V2CountryWorker, - V2WorkerVersion, - ) + from policyengine_simulation_contract.stage12_manifest import V2WorkerVersion class ComparisonSkipReason(StrEnum): @@ -92,13 +89,6 @@ def _skip(reason: ComparisonSkipReason) -> AdaptedComparisonRun: return AdaptedComparisonRun(report=None, skip_reason=reason) -def _country_worker(worker: V2WorkerVersion, country: str) -> V2CountryWorker | None: - return next( - (item for item in worker.countries if item.country == country), - None, - ) - - def _options_skip_reason(payload: dict[str, Any]) -> ComparisonSkipReason | None: """Return why the request's fields or options are unsupported, if they are.""" if any( @@ -189,12 +179,16 @@ def adapt_annual_comparison( evaluation_id: UUID, worker: V2WorkerVersion, ) -> AdaptedComparisonRun: - """Convert only the reviewed production request shape without guessing.""" + """Convert only the reviewed production request shape without guessing. + + Only a country the manifest marks as economy-capable is converted; any + other country is skipped as unsupported. + """ skip_reason = _options_skip_reason(payload) if skip_reason is not None: return _skip(skip_reason) country = payload.get("country") - if not isinstance(country, str) or _country_worker(worker, country) is None: + if not isinstance(country, str) or worker.economy_worker(country) is None: return _skip(ComparisonSkipReason.UNSUPPORTED_COUNTRY) bundle_country = next( item for item in worker.bundle.countries if item.country == country diff --git a/projects/policyengine-simulation-entry/tests/stage12_fixtures.py b/projects/policyengine-simulation-entry/tests/stage12_fixtures.py index fa4e83f6a..557e842a6 100644 --- a/projects/policyengine-simulation-entry/tests/stage12_fixtures.py +++ b/projects/policyengine-simulation-entry/tests/stage12_fixtures.py @@ -29,7 +29,12 @@ EVALUATION_ID = UUID("00000000-0000-0000-0000-000000000001") -def worker() -> V2WorkerVersion: +def worker(*, household_only: tuple[str, ...] = ()) -> V2WorkerVersion: + """Return a validated v2 worker for the US and the UK. + + A country in ``household_only`` has only a household function, so its + economy reports cannot run. + """ countries = [] country_workers = [] for country, package, package_version, dataset in ( @@ -60,10 +65,15 @@ def worker() -> V2WorkerVersion: ), ) ) + economy_capable = country not in household_only country_workers.append( V2CountryWorker( country=country, - single_simulation_callable=f"run_single_simulation_{country}", + economy_capable=economy_capable, + single_simulation_callable=( + f"run_single_simulation_{country}" if economy_capable else None + ), + household_callable=f"calculate_household_{country}", ) ) application_name = v2_application_name("5.2.0") @@ -86,8 +96,13 @@ def worker() -> V2WorkerVersion: validated_at=datetime(2026, 9, 14, tzinfo=UTC), validation_invocation_id="validation-1", country_validation_invocation_ids={ - "us": "validation-us", - "uk": "validation-uk", + item.country: f"validation-{item.country}" + for item in country_workers + if item.economy_capable + }, + household_validation_invocation_ids={ + item.country: f"household-validation-{item.country}" + for item in country_workers }, ), ) diff --git a/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py b/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py index 5751a9b24..21ff7a4d0 100644 --- a/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py +++ b/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py @@ -155,6 +155,17 @@ def test_adapter_returns_bounded_skip_reason(update, reason) -> None: assert result.skip_reason is reason +def test_adapter_skips_a_country_the_manifest_marks_household_only() -> None: + result = adapt_annual_comparison( + eligible_payload(), + evaluation_id=EVALUATION_ID, + worker=worker(household_only=("us",)), + ) + + assert result.report is None + assert result.skip_reason is ComparisonSkipReason.UNSUPPORTED_COUNTRY + + def _expected_simulation(role: str, execution_id: str, policy: dict) -> dict: dataset_uri = "hf://policyengine/data/populace_us_2024.h5@populace_us_2024-revision" return { diff --git a/projects/policyengine-simulation-entry/tests/test_stage12_backend.py b/projects/policyengine-simulation-entry/tests/test_stage12_backend.py index ec75a064b..5bb9a6b2d 100644 --- a/projects/policyengine-simulation-entry/tests/test_stage12_backend.py +++ b/projects/policyengine-simulation-entry/tests/test_stage12_backend.py @@ -29,8 +29,8 @@ class FakeLoader: - def __init__(self): - self.worker = worker() + def __init__(self, *, household_only: tuple[str, ...] = ()): + self.worker = worker(household_only=household_only) self.resolved_versions = [] def resolve(self, version=None): @@ -68,7 +68,14 @@ async def spawn(self, **call): return "modal-call-1" -def _backend(store, invoker, *, environment="staging", modal_environment="staging"): +def _backend( + store, + invoker, + *, + environment="staging", + modal_environment="staging", + household_only=(), +): runtime = MagicMock() runtime.capture_context.return_value = { "observability_id": "00000000-0000-4000-8000-000000000099", @@ -79,7 +86,7 @@ def _backend(store, invoker, *, environment="staging", modal_environment="stagin environment=environment, stage12_v2_manifest_environment=modal_environment, ), - manifest_loader=FakeLoader(), + manifest_loader=FakeLoader(household_only=household_only), store=store, invoker=invoker, runtime=runtime, @@ -347,6 +354,48 @@ def test_temporary_submission_rejects_unsupported_input_without_side_effects() - assert invoker.calls == [] +def test_temporary_submission_rejects_a_country_without_economy_reports() -> None: + store = FakeStore() + invoker = FakeInvoker() + + with pytest.raises(TemporaryStage12UnsupportedRequestError) as error: + asyncio.run( + _backend(store, invoker, household_only=("us",)).submit_temporary_report( + request_payload=eligible_payload(), + request_id="request-1", + observability_id=OBSERVABILITY_ID, + ) + ) + + assert error.value.reason == "unsupported_country" + assert store.events == [] + assert invoker.calls == [] + + +def test_automatic_dispatch_skips_a_country_without_economy_reports() -> None: + invoker = FakeInvoker() + backend = _backend(FakeStore(), invoker, household_only=("us",)) + + asyncio.run( + backend.dispatch_after_production( + request_payload=eligible_payload(), + production_response=_response(), + request_id="request-1", + observability_id=OBSERVABILITY_ID, + ) + ) + + assert invoker.calls == [] + backend._runtime.event.assert_called_once_with( + "stage12_comparison_run_skipped", + attributes={ + "request_id": "request-1", + "production_identity": "job-1", + "reason": "unsupported_country", + }, + ) + + def test_temporary_status_reads_coordinator_owned_postgres_state() -> None: store = FakeStore() invoker = FakeInvoker() diff --git a/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py b/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py index a8f8801ff..00dbab4ce 100644 --- a/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py +++ b/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py @@ -24,8 +24,9 @@ def build_worker(validation_payload: object) -> V2WorkerVersion: """Build the installed bundle's v2 worker entry from a validation record. - Raises ValueError if the record is invalid or the entry carries a - credential-like key. + Every bundle country has a single-simulation worker, so each is recorded + as economy-capable. Raises ValueError if the record is invalid or the + entry carries a credential-like key. """ resolved = load_stage12_bundle() validation = V2WorkerValidation.model_validate(validation_payload) @@ -35,7 +36,8 @@ def build_worker(validation_payload: object) -> V2WorkerVersion: countries=tuple( V2CountryWorker( country=country.country, - single_simulation_callable=(f"run_single_simulation_{country.country}"), + economy_capable=True, + single_simulation_callable=f"run_single_simulation_{country.country}", ) for country in resolved.bundle.countries ), diff --git a/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py b/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py index a808efcbe..cee40de8d 100644 --- a/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py +++ b/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py @@ -38,6 +38,23 @@ def test_publisher_uses_only_packaged_bundle_values() -> None: ) +def test_publisher_records_every_bundle_country_as_economy_capable() -> None: + worker = publisher.build_worker(_validation().model_dump(mode="json")) + + assert [ + ( + country.country, + country.economy_capable, + country.single_simulation_callable, + country.household_callable, + ) + for country in worker.countries + ] == [ + (country.country, True, f"run_single_simulation_{country.country}", None) + for country in publisher.load_stage12_bundle().bundle.countries + ] + + def test_publisher_rejects_validation_for_another_application() -> None: payload = _validation().model_dump(mode="json") payload["application_name"] = "policyengine-simulation-v2-py-another" From 0485255fe04397de03dbea9250bd7cdc5b22f201 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:19:44 +0400 Subject: [PATCH 007/124] Port the Public API's household input preparation The v2 household functions prepare a household exactly as the Public API's household calculation does before calculating it: /calculate-full adds every missing yearly, monthly and eternal variable for the year of the first age entered for "you"; deprecated inputs are dropped, including axes that vary them, with the Public API's warning text; unknown entity groups, variables, variables on the wrong entity, axis variables and policy parameters are reported in the order the household was sent, with the Public API's error types and messages; and the requested values are the null values, or with axes every value of a known variable, in document order. Co-Authored-By: Claude Opus 5.5 --- .../household_inputs.py | 477 ++++++++++++++++++ .../tests/test_household_inputs.py | 322 ++++++++++++ 2 files changed, 799 insertions(+) create mode 100644 projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py create mode 100644 projects/policyengine-simulation-executor/tests/test_household_inputs.py diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py new file mode 100644 index 000000000..47a909cd6 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py @@ -0,0 +1,477 @@ +"""Household input preparation, matching the Public API's in-process calculation. + +These functions reproduce, for the v2 household functions, how the Public API +prepares a household before calculating it: which period missing variables +are added for, which deprecated inputs are dropped (and the warning that says +so), which names are reported as unrecognized, and which values are +requested. Keeping the wording and order identical lets a v2 result be +compared with the Public API's result field by field. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.household_execution import HouseholdInputError + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from datetime import date + + from policyengine_core.taxbenefitsystems import TaxBenefitSystem + from policyengine_core.variables import Variable + from policyengine_simulation_contract.household_execution import InputErrorType + +# Removed or renamed model variables that callers may still send. The value is +# the migration hint shown in the warning. Matches the Public API's registry. +DEPRECATED_VARIABLES: dict[str, str] = { + "medical_out_of_pocket_expenses": ( + "Removed in policyengine-us 1.673.0. Migrate non-premium spending " + "to `other_medical_expenses` and premium spending to " + "`health_insurance_premiums`." + ), + "uc_has_limited_capability_for_work": ( + "Removed in policyengine-uk 2.129.0. Set " + "`uc_limited_capability_for_work` instead." + ), +} +# Variables with these definition periods are added when missing. +_ADDED_DEFINITION_PERIODS = frozenset({"year", "month", "eternity"}) +_AXES_KEY = "axes" + +# One value to calculate: entity plural, entity name, variable and period. +RequestedValue = tuple[str, str, str, str] + + +@dataclass(frozen=True) +class HouseholdVariable: + """One country-model variable, as household preparation needs it. + + ``default_value`` is the value the Public API's metadata reports: the + enum member name for an enum variable, the default for a boolean or + numeric variable, and None otherwise. + """ + + name: str + entity: str + definition_period: str + is_input: bool + default_value: bool | int | float | str | None + + +@dataclass(frozen=True) +class HouseholdEntity: + """One country-model entity: its key, plural and the plurals of its roles.""" + + key: str + plural: str + role_plurals: frozenset[str] + + +@dataclass(frozen=True) +class HouseholdModelMetadata: + """The variables, entities and policy parameters of one country model.""" + + variables: Mapping[str, HouseholdVariable] + entities: Mapping[str, HouseholdEntity] + parameters: frozenset[str] + + @classmethod + def from_tax_benefit_system( + cls, system: TaxBenefitSystem + ) -> HouseholdModelMetadata: + """Read the metadata the Public API builds from a tax-benefit system. + + Variables keep the system's order. Parameters are every descendant + whose name starts with ``gov``, as the Public API's metadata lists. + """ + entities = { + entity.key: HouseholdEntity( + key=entity.key, + plural=entity.plural, + role_plurals=frozenset( + role.plural + for role in getattr(entity, "roles", ()) + if role.plural is not None + ), + ) + for entity in system.entities + } + variables = { + name: HouseholdVariable( + name=name, + entity=variable.entity.key, + definition_period=variable.definition_period, + is_input=variable.is_input_variable(), + default_value=_metadata_default_value(variable), + ) + for name, variable in system.variables.items() + } + root = system.parameters + parameters = frozenset( + parameter.name + for parameter in (root.get_descendants() if root is not None else ()) + if parameter.name[:3] == "gov" + ) + return cls(variables=variables, entities=entities, parameters=parameters) + + def entity_by_plural(self) -> dict[str, HouseholdEntity]: + """Return the entities keyed by their plural.""" + return {entity.plural: entity for entity in self.entities.values()} + + +def _metadata_default_value(variable: Variable) -> bool | int | float | str | None: + default_value: object = variable.default_value + if variable.value_type.__name__ == "Enum": + return str(getattr(default_value, "name", default_value)) + if isinstance(default_value, (int, float, bool)): + return default_value + return None + + +def household_year(situation: Mapping[str, Any], *, today: date) -> str: + """Return the period missing yearly variables are added for. + + As in the Public API, this is the first period of the age entered for the + person named ``you``, or else the current calendar year. An age entered + without periods counts as no age. + """ + people = situation.get("people") + you = people.get("you") if isinstance(people, dict) else None + ages = you.get("age") if isinstance(you, dict) else None + if isinstance(ages, dict) and ages: + return str(next(iter(ages))) + return str(today.year) + + +def add_missing_variables( + situation: dict[str, Any], + metadata: HouseholdModelMetadata, + *, + today: date, +) -> None: + """Add every yearly, monthly and eternal variable each entity lacks, in place. + + Input variables get their default value and calculated variables get + None, which requests them, for the household's year. This is what the + Public API's ``/calculate-full`` route adds. + """ + year = household_year(situation, today=today) + for variable in metadata.variables.values(): + if variable.definition_period not in _ADDED_DEFINITION_PERIODS: + continue + entity_plural = metadata.entities[variable.entity].plural + for entity in situation.get(entity_plural, {}).values(): + if variable.name not in entity: + entity[variable.name] = { + year: variable.default_value if variable.is_input else None + } + + +@dataclass(frozen=True) +class DeprecatedVariableWarning: + """A removed or renamed variable was supplied and dropped before calculation.""" + + variable: str + entity_plural: str + entity_id: str + hint: str + + @property + def message(self) -> str: + """Return the warning naming the ignored input, where it was and the hint.""" + location = f"`{self.entity_plural}/{self.entity_id}`" + if self.entity_plural == _AXES_KEY: + location = f"`axes[{self.entity_id}].name`" + return ( + f"Input `{self.variable}` on {location} is deprecated and was " + f"ignored for this calculation. {self.hint}" + ) + + +@dataclass(frozen=True) +class DeprecatedInputsResult: + """A household copy without deprecated inputs, and one warning per input.""" + + household: dict[str, Any] + warnings: tuple[DeprecatedVariableWarning, ...] + + +def drop_deprecated_inputs(situation: Mapping[str, Any]) -> DeprecatedInputsResult: + """Return a copy of ``situation`` without deprecated inputs, with warnings. + + Each deprecated variable on an entity, and each axis that varies one, is + removed with one warning; an axis group left empty is removed, and + ``axes`` is removed when no group is left. ``situation`` is not changed. + """ + warnings: list[DeprecatedVariableWarning] = [] + household = copy.deepcopy(dict(situation)) + for entity_plural, entity_group in household.items(): + if entity_plural == _AXES_KEY or not isinstance(entity_group, dict): + continue + for entity_id, variables in entity_group.items(): + if isinstance(variables, dict): + warnings.extend( + _drop_deprecated_variables(entity_plural, entity_id, variables) + ) + _drop_deprecated_axes(household, warnings) + return DeprecatedInputsResult(household=household, warnings=tuple(warnings)) + + +def _drop_deprecated_variables( + entity_plural: str, entity_id: str, variables: dict[str, Any] +) -> list[DeprecatedVariableWarning]: + warnings: list[DeprecatedVariableWarning] = [] + for variable_name in list(variables): + hint = DEPRECATED_VARIABLES.get(variable_name) + if hint is None: + continue + warnings.append( + DeprecatedVariableWarning( + variable=variable_name, + entity_plural=entity_plural, + entity_id=entity_id, + hint=hint, + ) + ) + del variables[variable_name] + return warnings + + +def _drop_deprecated_axes( + household: dict[str, Any], warnings: list[DeprecatedVariableWarning] +) -> None: + axes = household.get(_AXES_KEY) + if not isinstance(axes, list): + return + changed = False + retained_groups = [] + for group_index, group in enumerate(axes): + retained_axes = [] + for axis_index, axis in enumerate(group): + warning = _deprecated_axis_warning(axis, f"{group_index}][{axis_index}") + if warning is None: + retained_axes.append(axis) + continue + warnings.append(warning) + changed = True + if retained_axes: + retained_groups.append(retained_axes) + if not changed: + return + if retained_groups: + household[_AXES_KEY] = retained_groups + else: + del household[_AXES_KEY] + + +def _deprecated_axis_warning( + axis: object, location: str +) -> DeprecatedVariableWarning | None: + if not isinstance(axis, dict): + return None + variable_name = str(axis.get("name")) + hint = DEPRECATED_VARIABLES.get(variable_name) + if hint is None: + return None + return DeprecatedVariableWarning( + variable=variable_name, entity_plural=_AXES_KEY, entity_id=location, hint=hint + ) + + +def _input_error_message( + input_type: InputErrorType, + name: str, + path: str, + entity_plurals: tuple[str, str] | None, +) -> str: + if input_type == "household_entity": + return f"Unrecognized household entity group `{name}` at `{path}`." + if input_type == "household_variable_wrong_entity" and entity_plurals: + expected, actual = entity_plurals + return ( + f"Household variable `{name}` belongs on `{expected}`, " + f"not `{actual}`, at `{path}`." + ) + if input_type == "household_axis_variable": + return f"Unrecognized household axis variable `{name}` at `{path}`." + if input_type == "policy_parameter": + return f"Unrecognized policy parameter `{name}` at `{path}`." + return f"Unrecognized household variable `{name}` at `{path}`." + + +def _input_error( + input_type: InputErrorType, + name: str, + path: str, + *, + entity_plurals: tuple[str, str] | None = None, +) -> HouseholdInputError: + """Return one unrecognized input, worded as the Public API words it. + + ``entity_plurals`` is the (expected, actual) pair for a variable placed on + the wrong entity. + """ + expected, actual = entity_plurals or (None, None) + return HouseholdInputError( + type=input_type, + name=name, + path=path, + message=_input_error_message(input_type, name, path, entity_plurals), + expected_entity_plural=expected, + actual_entity_plural=actual, + ) + + +def find_unrecognized_household_inputs( + situation: Mapping[str, Any], + metadata: HouseholdModelMetadata, +) -> list[HouseholdInputError]: + """Find unknown entity groups, variables and axis variables, in document order. + + Also reports a known variable placed on the wrong entity. Role membership + keys (such as ``members``) are not variables and are skipped. + """ + errors: list[HouseholdInputError] = [] + entity_by_plural = metadata.entity_by_plural() + for entity_plural, entity_group in situation.items(): + if entity_plural == _AXES_KEY: + errors.extend(_find_unrecognized_axes(entity_group, metadata)) + continue + entity = entity_by_plural.get(entity_plural) + if entity is None: + errors.append( + _input_error( + "household_entity", entity_plural, f"household.{entity_plural}" + ) + ) + continue + if not isinstance(entity_group, dict): + continue + for entity_id, entity_inputs in entity_group.items(): + if isinstance(entity_inputs, dict): + errors.extend( + _find_unrecognized_entity_inputs( + entity, entity_id, entity_inputs, metadata + ) + ) + return errors + + +def _find_unrecognized_entity_inputs( + entity: HouseholdEntity, + entity_id: str, + entity_inputs: Mapping[str, Any], + metadata: HouseholdModelMetadata, +) -> list[HouseholdInputError]: + errors: list[HouseholdInputError] = [] + for variable_name in entity_inputs: + if variable_name in entity.role_plurals: + continue + variable = metadata.variables.get(variable_name) + path = f"household.{entity.plural}.{entity_id}.{variable_name}" + if variable is None: + errors.append(_input_error("household_variable", variable_name, path)) + continue + expected_entity = metadata.entities.get(variable.entity) + if expected_entity is not None and expected_entity.plural != entity.plural: + errors.append( + _input_error( + "household_variable_wrong_entity", + variable_name, + path, + entity_plurals=(expected_entity.plural, entity.plural), + ) + ) + return errors + + +def _find_unrecognized_axes( + axes: object, metadata: HouseholdModelMetadata +) -> list[HouseholdInputError]: + if not isinstance(axes, list): + return [] + errors: list[HouseholdInputError] = [] + for group_index, group in enumerate(axes): + for axis_index, axis in enumerate(group): + if not isinstance(axis, dict) or "name" not in axis: + continue + variable_name = str(axis["name"]) + if variable_name in metadata.variables: + continue + errors.append( + _input_error( + "household_axis_variable", + variable_name, + f"household.axes[{group_index}][{axis_index}].name", + ) + ) + return errors + + +def find_unrecognized_policy_inputs( + parameters: Iterable[str], + metadata: HouseholdModelMetadata, +) -> list[HouseholdInputError]: + """Find policy parameter paths the country model does not have, in order.""" + return [ + _input_error("policy_parameter", name, f"policy.{name}") + for name in parameters + if name not in metadata.parameters + ] + + +def unrecognized_inputs_message(errors: Iterable[HouseholdInputError]) -> str: + """Join the errors' messages as the Public API's error response does.""" + return "Unrecognized calculate input(s): " + "; ".join( + error.message for error in errors + ) + + +def requested_computations( + situation: Mapping[str, Any], + *, + include_provided_values: bool, + variable_names: set[str] | frozenset[str] | None = None, +) -> list[RequestedValue]: + """List the (entity plural, entity name, variable, period) values to calculate. + + A value is requested when it is None or, with ``include_provided_values``, + whatever it is. ``variable_names`` keeps only those variables. Values are + listed in document order, as the Public API lists them. + """ + computations: list[RequestedValue] = [] + for entity_plural, entities in situation.items(): + if not isinstance(entities, dict): + continue + for entity_id, variables in entities.items(): + if isinstance(variables, dict): + computations.extend( + _requested_entity_values( + (entity_plural, entity_id), + variables, + include_provided_values=include_provided_values, + variable_names=variable_names, + ) + ) + return computations + + +def _requested_entity_values( + entity: tuple[str, str], + variables: Mapping[str, Any], + *, + include_provided_values: bool, + variable_names: set[str] | frozenset[str] | None, +) -> list[RequestedValue]: + entity_plural, entity_id = entity + return [ + (entity_plural, entity_id, variable_name, str(period)) + for variable_name, periods in variables.items() + if isinstance(periods, dict) + and (variable_names is None or variable_name in variable_names) + for period, value in periods.items() + if include_provided_values or value is None + ] diff --git a/projects/policyengine-simulation-executor/tests/test_household_inputs.py b/projects/policyengine-simulation-executor/tests/test_household_inputs.py new file mode 100644 index 000000000..9f29025cf --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_inputs.py @@ -0,0 +1,322 @@ +"""Tests for household input preparation, ported from the Public API.""" + +from __future__ import annotations + +from datetime import date +from types import SimpleNamespace + +from policyengine_simulation_executor.household_inputs import ( + HouseholdEntity, + HouseholdModelMetadata, + HouseholdVariable, + add_missing_variables, + drop_deprecated_inputs, + find_unrecognized_household_inputs, + find_unrecognized_policy_inputs, + household_year, + requested_computations, + unrecognized_inputs_message, +) + +TODAY = date(2027, 3, 1) + + +def _metadata() -> HouseholdModelMetadata: + variables = [ + HouseholdVariable("age", "person", "year", is_input=True, default_value=40), + HouseholdVariable( + "employment_income", "person", "year", is_input=True, default_value=0 + ), + HouseholdVariable( + "is_blind", "person", "eternity", is_input=True, default_value=False + ), + HouseholdVariable( + "monthly_rent", "household", "month", is_input=True, default_value=0 + ), + HouseholdVariable( + "state_name", "household", "year", is_input=True, default_value="CA" + ), + HouseholdVariable( + "household_net_income", + "household", + "year", + is_input=False, + default_value=0, + ), + HouseholdVariable( + "daily_amount", "household", "day", is_input=True, default_value=0 + ), + ] + return HouseholdModelMetadata( + variables={variable.name: variable for variable in variables}, + entities={ + "person": HouseholdEntity("person", "people", frozenset()), + "household": HouseholdEntity( + "household", "households", frozenset({"members"}) + ), + }, + parameters=frozenset({"gov.tax.rate", "gov.tax.brackets[0].rate"}), + ) + + +def _situation() -> dict: + return { + "people": {"you": {"age": {"2026": 30}}, "partner": {}}, + "households": {"home": {"members": ["you", "partner"]}}, + } + + +def test_household_year_is_the_first_age_period_of_you() -> None: + assert household_year(_situation(), today=TODAY) == "2026" + assert household_year({"people": {"partner": {}}}, today=TODAY) == "2027" + assert household_year({"people": {"you": {"age": 30}}}, today=TODAY) == "2027" + + +def test_add_missing_variables_matches_the_public_api() -> None: + situation = _situation() + + add_missing_variables(situation, _metadata(), today=TODAY) + + assert situation == { + "people": { + "you": { + "age": {"2026": 30}, + "employment_income": {"2026": 0}, + "is_blind": {"2026": False}, + }, + "partner": { + "age": {"2026": 40}, + "employment_income": {"2026": 0}, + "is_blind": {"2026": False}, + }, + }, + "households": { + "home": { + "members": ["you", "partner"], + "monthly_rent": {"2026": 0}, + "state_name": {"2026": "CA"}, + # Calculated variables are requested, never defaulted. + "household_net_income": {"2026": None}, + } + }, + } + + +def test_deprecated_inputs_are_dropped_with_public_api_warnings() -> None: + situation = { + "people": {"you": {"age": {"2026": 30}, "medical_out_of_pocket_expenses": 5}}, + "axes": [ + [{"name": "uc_has_limited_capability_for_work", "count": 2}], + [{"name": "employment_income", "count": 2}], + ], + } + + result = drop_deprecated_inputs(situation) + + assert result.household == { + "people": {"you": {"age": {"2026": 30}}}, + "axes": [[{"name": "employment_income", "count": 2}]], + } + assert "medical_out_of_pocket_expenses" in situation["people"]["you"] + assert [warning.message for warning in result.warnings] == [ + "Input `medical_out_of_pocket_expenses` on `people/you` is deprecated and " + "was ignored for this calculation. Removed in policyengine-us 1.673.0. " + "Migrate non-premium spending to `other_medical_expenses` and premium " + "spending to `health_insurance_premiums`.", + "Input `uc_has_limited_capability_for_work` on `axes[0][0].name` is " + "deprecated and was ignored for this calculation. Removed in " + "policyengine-uk 2.129.0. Set `uc_limited_capability_for_work` instead.", + ] + + +def test_axes_left_empty_by_deprecated_inputs_are_removed() -> None: + situation = { + "people": {"you": {}}, + "axes": [[{"name": "medical_out_of_pocket_expenses", "count": 2}]], + } + + assert "axes" not in drop_deprecated_inputs(situation).household + + +def test_unrecognized_household_inputs_use_public_api_types_and_messages() -> None: + situation = { + "people": { + "you": {"age": {"2026": 30}, "not_a_variable": 1, "state_name": "CA"} + }, + "households": {"home": {"members": ["you"]}}, + "pets": {"rex": {}}, + "axes": [[{"name": "unknown_axis", "count": 2}]], + } + + errors = find_unrecognized_household_inputs(situation, _metadata()) + + assert [error.model_dump(mode="json") for error in errors] == [ + { + "type": "household_variable", + "name": "not_a_variable", + "path": "household.people.you.not_a_variable", + "message": ( + "Unrecognized household variable `not_a_variable` at " + "`household.people.you.not_a_variable`." + ), + }, + { + "type": "household_variable_wrong_entity", + "name": "state_name", + "path": "household.people.you.state_name", + "message": ( + "Household variable `state_name` belongs on `households`, not " + "`people`, at `household.people.you.state_name`." + ), + "expected_entity_plural": "households", + "actual_entity_plural": "people", + }, + { + "type": "household_entity", + "name": "pets", + "path": "household.pets", + "message": ( + "Unrecognized household entity group `pets` at `household.pets`." + ), + }, + { + "type": "household_axis_variable", + "name": "unknown_axis", + "path": "household.axes[0][0].name", + "message": ( + "Unrecognized household axis variable `unknown_axis` at " + "`household.axes[0][0].name`." + ), + }, + ] + assert unrecognized_inputs_message(errors[:1]) == ( + "Unrecognized calculate input(s): Unrecognized household variable " + "`not_a_variable` at `household.people.you.not_a_variable`." + ) + + +def test_unrecognized_policy_parameters_are_reported_by_path() -> None: + errors = find_unrecognized_policy_inputs( + {"gov.tax.rate": {}, "gov.tax.brackets[0].rate": {}, "gov.missing": {}}, + _metadata(), + ) + + assert [error.model_dump(mode="json") for error in errors] == [ + { + "type": "policy_parameter", + "name": "gov.missing", + "path": "policy.gov.missing", + "message": "Unrecognized policy parameter `gov.missing` at " + "`policy.gov.missing`.", + } + ] + + +def test_deprecated_inputs_leave_the_callers_household_unchanged() -> None: + situation = {"people": {"you": {"medical_out_of_pocket_expenses": 5}}} + + result = drop_deprecated_inputs(situation) + + assert result.household == {"people": {"you": {}}} + assert situation == {"people": {"you": {"medical_out_of_pocket_expenses": 5}}} + + +def test_a_household_without_deprecated_inputs_keeps_its_axes() -> None: + situation = { + "people": {"you": {}}, + "axes": [[{"name": "employment_income", "count": 2}]], + } + + result = drop_deprecated_inputs(situation) + + assert result.household == situation + assert result.warnings == () + + +def test_requested_computations_are_the_null_values_without_axes() -> None: + situation = { + "people": {"you": {"age": {"2026": 30}, "employment_income": {"2026": None}}}, + "households": { + "home": {"members": ["you"], "household_net_income": {"2026": None}} + }, + } + + assert requested_computations(situation, include_provided_values=False) == [ + ("people", "you", "employment_income", "2026"), + ("households", "home", "household_net_income", "2026"), + ] + + +def test_requested_computations_with_axes_cover_every_known_variable() -> None: + situation = { + "people": {"you": {"age": {"2026": 30}, "unknown": {"2026": 1}}}, + "households": {"home": {"members": ["you"]}}, + "axes": [[{"name": "age", "count": 2, "min": 0, "max": 1}]], + } + + assert requested_computations( + situation, + include_provided_values=True, + variable_names={"age", "employment_income"}, + ) == [("people", "you", "age", "2026")] + + +def test_metadata_is_read_from_a_tax_benefit_system() -> None: + class Enum: + pass + + class Level: + name = "LOW" + + person = SimpleNamespace(key="person", plural="people", is_person=True) + household = SimpleNamespace( + key="household", + plural="households", + is_person=False, + roles=[SimpleNamespace(plural="members"), SimpleNamespace(plural=None)], + ) + + def variable(name, entity, value_type, default_value, *, is_input=True): + return SimpleNamespace( + entity=entity, + definition_period="year", + value_type=value_type, + default_value=default_value, + is_input_variable=lambda: is_input, + ) + + parameters = SimpleNamespace( + get_descendants=lambda: [ + SimpleNamespace(name="gov.tax.rate"), + SimpleNamespace(name="calibration.factor"), + ] + ) + system = SimpleNamespace( + entities=[person, household], + variables={ + "age": variable("age", person, int, 40), + "level": variable("level", household, Enum, Level()), + "label": variable("label", household, str, "x"), + "start": variable("start", person, date, date(2020, 1, 1)), + "net_income": variable("net_income", household, float, 0.0, is_input=False), + }, + parameters=parameters, + ) + + metadata = HouseholdModelMetadata.from_tax_benefit_system(system) + + assert metadata.entities["household"] == HouseholdEntity( + "household", "households", frozenset({"members"}) + ) + assert metadata.entities["person"].role_plurals == frozenset() + assert [ + (item.name, item.entity, item.default_value, item.is_input) + for item in metadata.variables.values() + ] == [ + ("age", "person", 40, True), + ("level", "household", "LOW", True), + ("label", "household", None, True), + ("start", "person", None, True), + ("net_income", "household", 0.0, False), + ] + assert metadata.parameters == frozenset({"gov.tax.rate"}) From 7aeb91407447b430b59431218370dbde3cedcb3c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:22:51 +0400 Subject: [PATCH 008/124] Resolve household SPM selections and receipts like the Public API A US household's SPM selection is merged over the installed bundle's certified defaults with the Public API's rules and error codes: settings for any other country, or for a US model bundle without canonical SPM, are unsupported; a model that can run canonical SPM without a certified configuration fails closed; a forecast hash the bundle lacks is an invalid setting from a request but an unavailable configuration from a saved household; and an unknown scenario, county vintage or area is reported with the Public API's code and message. After each calculation, the country model's SPM receipt is reduced to the compact public receipt and checked against the resolved selection. SPM errors that end a calculation are recognized through wrapped causes, as the Public API recognizes them. The executor's SPM module now exposes the certified forecast it already loads once per process. Co-Authored-By: Claude Opus 5.5 --- .../household_spm.py | 418 ++++++++++++++++++ .../policyengine_simulation_executor/spm.py | 9 + .../tests/test_household_spm.py | 294 ++++++++++++ 3 files changed, 721 insertions(+) create mode 100644 projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py create mode 100644 projects/policyengine-simulation-executor/tests/test_household_spm.py diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py new file mode 100644 index 000000000..2c5b61d29 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py @@ -0,0 +1,418 @@ +"""SPM selection and receipts for v2 household calculations. + +This reproduces the Public API's household SPM handling, so that a v2 +household result and error carry the same SPM codes, messages and receipt: + +- ``resolve_household_spm_selection`` merges a caller's selection over the + installed bundle's certified defaults, with the Public API's error codes; +- ``household_spm_error_detail`` recognizes the SPM errors that end a + calculation; +- ``household_spm_provenance`` reduces the country model's receipt to the + compact public receipt and checks it matches the resolved selection. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, NoReturn + +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMInputError, + SPMProvenance, + SPMRuntimeVersions, + SPMSelection, + build_spm_provenance, +) +from pydantic import ValidationError + +if TYPE_CHECKING: + from collections.abc import Callable + + from spm_calculator.rolling_forecast import SPMForecast + +# Country SPM errors about the household's own inputs. When the caller did not +# choose SPM settings, a value that needs one of these is left uncalculated +# instead of failing the calculation. +HOUSEHOLD_SPM_INPUT_ERROR_CODES = frozenset( + { + "SPM_GEOGRAPHY_REQUIRED", + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM_COMPOSITION_REQUIRED", + "SPM_YEAR_UNAVAILABLE", + } +) +_SUPPORTED_COUNTY_VINTAGE = "2020" +_CONFIGURATION_UNAVAILABLE = "SPM_CONFIGURATION_UNAVAILABLE" +_SETTINGS_INVALID = "SPM_SETTINGS_INVALID" +_SETTINGS_UNSUPPORTED = "SPM_SETTINGS_UNSUPPORTED" + + +def _readable_error(error: BaseException) -> str: + """Render a validation error as a client-facing message, without its URL.""" + if not isinstance(error, ValidationError): + return str(error) + return "; ".join( + ": ".join( + part + for part in ( + ".".join(str(item) for item in detail["loc"]), + detail["msg"].removeprefix("Value error, "), + ) + if part + ) + for detail in error.errors() + ) + + +def _current_bundle() -> object: + """Return the installed PolicyEngine.py bundle manifest.""" + from policyengine.bundle import get_current_bundle + + return get_current_bundle() + + +def _bundle_spm_configuration() -> object: + """Return the installed bundle's SPM measurement configuration, if any. + + Raises SPMInputError when the bundle manifest cannot be read. + """ + try: + bundle = _current_bundle() + except (AttributeError, ImportError, OSError, TypeError, ValueError): + bundle = None + if not isinstance(bundle, Mapping): + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, "The installed bundle manifest is invalid" + ) + measurements = bundle.get("measurements") + return measurements.get("spm") if isinstance(measurements, Mapping) else None + + +def _simulation_supports_spm(simulation_type: Callable[..., object]) -> bool: + """Whether a country ``Simulation`` constructor implements canonical SPM.""" + try: + parameters = inspect.signature(simulation_type).parameters + except (TypeError, ValueError): + return False + return "spm" in parameters or ( + hasattr(simulation_type, "spm_config") + and callable(getattr(simulation_type, "spm_provenance", None)) + ) + + +def _us_model_supports_spm() -> bool: + """Whether the installed US model implements canonical SPM. + + A model this build cannot import has no canonical SPM. + """ + try: + from policyengine_us import Simulation + except (AttributeError, ImportError, OSError, TypeError, ValueError): + return False + return _simulation_supports_spm(Simulation) + + +def _forecast(expected_sha256: str) -> SPMForecast: + from policyengine_simulation_executor.spm import certified_spm_forecast + + return certified_spm_forecast(expected_sha256) + + +def _certified_defaults(configured: object) -> tuple[SPMSelection, SPMForecast]: + """Return the bundle's pinned SPM defaults and the forecast they name. + + Raises ValueError (or an import or loading error) when the configuration + pins no forecast hash or scenario, or the US model lacks canonical SPM. + """ + defaults = SPMSelection.model_validate(configured) + if defaults.forecast_content_sha256 is None or defaults.scenario is None: + msg = "The bundle must pin the SPM artifact hash and scenario" + raise ValueError(msg) + if not _us_model_supports_spm(): + msg = "The installed US model does not support canonical SPM" + raise ValueError(msg) + return defaults, _forecast(defaults.forecast_content_sha256) + + +def _validated_selection(values: dict[str, Any], forecast: SPMForecast) -> SPMSelection: + """Validate merged settings against the forecast; raise ValueError if invalid.""" + resolved = SPMSelection.model_validate(values) + forecast.entry(forecast.years[0], scenario=resolved.scenario, as_of=resolved.as_of) + if resolved.county_vintage != _SUPPORTED_COUNTY_VINTAGE: + msg = "Unsupported county vintage: use 2020" + raise ValueError(msg) + return resolved + + +def _validate_metro_selection(forecast: SPMForecast, selection: SPMSelection) -> None: + """Raise SPM_GEOGRAPHY_UNAVAILABLE unless the area is in some forecast year.""" + for year in forecast.years: + try: + forecast.geography_factor( + year, + "renter", + scenario=selection.scenario, + kind="metro", + geoid=selection.geography_id, + as_of=selection.as_of, + ) + except ValueError: + continue + return + error_code = "SPM_GEOGRAPHY_UNAVAILABLE" + raise SPMInputError( + error_code, + f"SPM area is unavailable in the selected artifact: {selection.geography_id}", + ) + + +def _merged_selection(chosen: SPMSelection, defaults: SPMSelection) -> dict[str, Any]: + """Overlay the caller's choices on the defaults, as the Public API does. + + Changing the geography kind also replaces an inherited geography ID, and + the forecast hash always comes from the bundle. + """ + values = {name: getattr(defaults, name) for name in SPMSelection.model_fields} + values.update(chosen.model_dump(exclude_unset=True)) + if ( + "geography_kind" in chosen.model_fields_set + and chosen.geography_kind != defaults.geography_kind + ): + values["geography_id"] = chosen.geography_id + values["forecast_content_sha256"] = defaults.forecast_content_sha256 + values["scenario"] = chosen.scenario or defaults.scenario + return values + + +def _certified_configuration(*, selection_sent: bool) -> Mapping[str, object] | None: + """Return the bundle's certified SPM settings, or None for a legacy bundle. + + Raises SPM_CONFIGURATION_UNAVAILABLE for an unreadable bundle or for a US + model that can run canonical SPM without a certified configuration, and + SPM_SETTINGS_UNSUPPORTED when settings were sent to a legacy bundle. + """ + configured = _bundle_spm_configuration() + if isinstance(configured, Mapping): + return configured + if _us_model_supports_spm(): + # A model that can run canonical SPM must never run it uncertified. + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, + "The installed bundle has no certified SPM measurement configuration", + ) + if not selection_sent: + return None + raise SPMInputError( + _SETTINGS_UNSUPPORTED, + "This US model bundle does not support canonical SPM settings", + ) + + +def _raise_unavailable_forecast(*, saved_household: bool) -> NoReturn: + """Reject a selection naming a forecast this bundle does not have.""" + if saved_household: + # A saved selection is the household's identity, not a request: this + # deployment lacks the forecast it was measured against. + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, + "This deployment does not have the SPM artifact this household's " + "saved selection was measured against", + ) + raise SPMInputError( + _SETTINGS_INVALID, + "SPM selection does not match this bundle's artifact hash", + ) + + +def _resolve_certified_selection( + chosen: SPMSelection, + configured: Mapping[str, object], + *, + saved_household: bool, +) -> dict[str, Any]: + """Merge the caller's settings over the certified defaults and validate them.""" + try: + defaults, forecast = _certified_defaults(configured) + except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, _readable_error(error) + ) from error + if chosen.forecast_content_sha256 not in (None, defaults.forecast_content_sha256): + _raise_unavailable_forecast(saved_household=saved_household) + try: + resolved = _validated_selection(_merged_selection(chosen, defaults), forecast) + except ValueError as error: + raise SPMInputError(_SETTINGS_INVALID, _readable_error(error)) from error + if resolved.geography_kind == "metro": + _validate_metro_selection(forecast, resolved) + return resolved.model_dump(mode="json") + + +def resolve_household_spm_selection( + country: str, + selection: SPMSelection | None, + *, + saved_household: bool, +) -> dict[str, Any] | None: + """Resolve a household's SPM selection against the installed bundle. + + ``selection`` is None when the caller made no choice. Returns the complete + resolved selection, or None when no SPM applies (any country but the US, + or a bundle whose US model predates canonical SPM). Raises SPMInputError + with the Public API's code and message otherwise. ``saved_household`` says + the selection was read from a saved household, which changes only how a + forecast hash the bundle no longer carries is reported. + """ + if country != "us": + if selection is not None: + raise SPMInputError( + _SETTINGS_UNSUPPORTED, "SPM settings are only available for the US" + ) + return None + configured = _certified_configuration(selection_sent=selection is not None) + if configured is None: + return None + return _resolve_certified_selection( + selection if selection is not None else SPMSelection(), + configured, + saved_household=saved_household, + ) + + +def household_spm_error_detail(error: BaseException) -> SPMErrorDetail | None: + """Return the SPM error that ends a calculation, following wrapped causes. + + Recognizes this module's own ``SPMInputError`` and a country model's error + carrying one of ``HOUSEHOLD_SPM_INPUT_ERROR_CODES``, as the Public API does. + """ + visited: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in visited: + visited.add(id(current)) + code = getattr(current, "code", None) + if isinstance(current, SPMInputError) or ( + isinstance(current, ValueError) and code in HOUSEHOLD_SPM_INPUT_ERROR_CODES + ): + return SPMErrorDetail(code=str(code), message=str(current)) + current = current.__cause__ or current.__context__ + return None + + +def _required_text(source: Mapping[str, object], field_name: str) -> str: + value = source.get(field_name) + if not isinstance(value, str) or not value: + msg = f"Country SPM receipt requires {field_name}" + raise ValueError(msg) + return value + + +def _complete_settings(settings: object) -> SPMSelection | None: + """Return the selection only when every field is present.""" + try: + selection = SPMSelection.model_validate(settings) + except ValidationError: + return None + if set(SPMSelection.model_fields) - selection.model_fields_set: + return None + return selection + + +def _receipt_mapping(receipt: Mapping[str, object], field_name: str) -> Mapping: + value = receipt.get(field_name) + if not isinstance(value, Mapping): + msg = f"Country SPM receipt requires {field_name}" + raise TypeError(msg) + return value + + +def _compact_receipt( + selection: SPMSelection, receipt: Mapping[str, object] +) -> SPMProvenance: + """Reduce the country model's rich receipt to the compact public receipt.""" + forecast_sha256 = _required_text(receipt, "forecast_sha256") + if ( + forecast_sha256 != selection.forecast_content_sha256 + or _required_text(receipt, "scenario") != selection.scenario + or _required_text(receipt, "geography_kind") != selection.geography_kind + ): + msg = "Country SPM receipt differs from the SPM configuration" + raise ValueError(msg) + source_years = _receipt_mapping(receipt, "years") + if any(not isinstance(year, str) for year in source_years): + msg = "Country SPM receipt years must be strings" + raise ValueError(msg) + return build_spm_provenance( + forecast_id=_required_text(receipt, "forecast_id"), + forecast_sha256=forecast_sha256, + selection=selection, + years=tuple(sorted(source_years)), + runtime_versions=SPMRuntimeVersions.model_validate( + _receipt_mapping(receipt, "runtime_versions") + ), + ) + + +def _country_receipt(simulation: object) -> SPMProvenance: + """Reduce the simulation's receipt. + + Raises ValueError or TypeError if the receipt cannot be certified. + """ + selection = _complete_settings(getattr(simulation, "spm_config", None)) + if selection is None: + msg = "Country SPM configuration is incomplete" + raise ValueError(msg) + receipt_method = getattr(simulation, "spm_provenance", None) + source = receipt_method() if callable(receipt_method) else None + if not isinstance(source, Mapping): + msg = "Country SPM receipt must be an object" + raise TypeError(msg) + return _compact_receipt(selection, source) + + +def _calculation_receipt(simulation: object) -> SPMProvenance | None: + """Return the simulation's compact SPM receipt; None without SPM support.""" + if not hasattr(simulation, "spm_config"): + return None + try: + return _country_receipt(simulation) + except (TypeError, ValueError) as error: + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, + "The installed country model reported an SPM receipt this API cannot " + f"certify: {_readable_error(error)}", + ) from error + + +def household_spm_provenance( + simulation: object, + expected_selection: Mapping[str, object] | None, +) -> SPMProvenance | None: + """Return the simulation's SPM receipt, checked against the resolved selection. + + Raises SPMInputError for a receipt that cannot be certified, and + ValueError for a receipt where none was expected, a missing receipt, or a + receipt for another selection. + """ + receipt = _calculation_receipt(simulation) + if expected_selection is None: + if receipt is None: + return None + msg = "Unexpected SPM provenance for a calculation without SPM" + raise ValueError(msg) + if receipt is None: + msg = "The calculation produced no SPM receipt" + raise ValueError(msg) + receipt_selection = { + "forecast_content_sha256": receipt.forecast_sha256, + "scenario": receipt.scenario, + "geography_kind": receipt.geography_kind, + "geography_id": receipt.geography_id, + "county_vintage": receipt.county_vintage, + "as_of": receipt.as_of.isoformat() if receipt.as_of is not None else None, + } + if receipt_selection != dict(expected_selection): + msg = "SPM calculation configuration differs from the request" + raise ValueError(msg) + return receipt diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py index b5c2490a7..cbc29f387 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py @@ -34,6 +34,15 @@ def _forecast(expected_sha256: str | None) -> SPMForecast: return load_forecast(expected_sha256=expected_sha256) +def certified_spm_forecast(expected_sha256: str) -> SPMForecast: + """Return the packaged SPM forecast whose content has ``expected_sha256``. + + Loaded once per process and digest; raises when the packaged forecast has + another digest. + """ + return _forecast(expected_sha256) + + @lru_cache(maxsize=1) def runtime_spm_capability() -> SPMCapability | None: """Return the installed image's certified SPM capability. diff --git a/projects/policyengine-simulation-executor/tests/test_household_spm.py b/projects/policyengine-simulation-executor/tests/test_household_spm.py new file mode 100644 index 000000000..1a3cd975a --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_spm.py @@ -0,0 +1,294 @@ +"""Tests for household SPM selection and receipts, ported from the Public API.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_contract.spm import SPMInputError, SPMSelection +from policyengine_simulation_executor import household_spm + +FORECAST_SHA = "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173" +DEFAULTS = { + "as_of": None, + "county_vintage": "2020", + "forecast_content_sha256": FORECAST_SHA, + "geography_id": None, + "geography_kind": "county", + "scenario": "ce_trend", +} +RUNTIME_VERSIONS = { + "policyengine": "6.7.5", + "policyengine-core": "3.33.0", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", +} + + +class FakeForecast: + years = (2025, 2026) + + def __init__(self, *, metro_areas=("31080",)): + self.metro_areas = metro_areas + + def entry(self, year, *, scenario, as_of): + if scenario not in {"ce_trend", "flat"}: + msg = f"Unknown forecast scenario: {scenario}" + raise ValueError(msg) + + def geography_factor(self, year, tenure, **options): + if options["geoid"] not in self.metro_areas: + msg = f"unknown area {options['geoid']}" + raise ValueError(msg) + return 1.0 + + +@pytest.fixture +def certified_bundle(monkeypatch): + monkeypatch.setattr( + household_spm, "_bundle_spm_configuration", lambda: dict(DEFAULTS) + ) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + monkeypatch.setattr(household_spm, "_forecast", lambda sha: FakeForecast()) + + +def _resolve(selection=None, *, country="us", saved_household=False): + return household_spm.resolve_household_spm_selection( + country, + None if selection is None else SPMSelection.model_validate(selection), + saved_household=saved_household, + ) + + +def _error(selection=None, **options) -> SPMInputError: + with pytest.raises(SPMInputError) as raised: + _resolve(selection, **options) + return raised.value + + +def test_omitted_selection_resolves_the_certified_defaults(certified_bundle) -> None: + assert _resolve() == DEFAULTS + + +def test_explicit_choices_override_defaults(certified_bundle) -> None: + assert _resolve({"geography_kind": "metro", "geography_id": "31080"}) == { + **DEFAULTS, + "geography_kind": "metro", + "geography_id": "31080", + } + assert _resolve({"scenario": "flat"})["scenario"] == "flat" + + +def test_other_countries_accept_no_selection(certified_bundle) -> None: + assert _resolve(country="uk") is None + error = _error({}, country="uk") + assert (error.code, error.message) == ( + "SPM_SETTINGS_UNSUPPORTED", + "SPM settings are only available for the US", + ) + + +def test_a_foreign_artifact_hash_is_reported_by_where_it_came_from( + certified_bundle, +) -> None: + selection = {"forecast_content_sha256": "f" * 64} + + requested = _error(selection) + saved = _error(selection, saved_household=True) + + assert (requested.code, requested.message) == ( + "SPM_SETTINGS_INVALID", + "SPM selection does not match this bundle's artifact hash", + ) + assert saved.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert "saved selection" in saved.message + + +@pytest.mark.parametrize( + ("selection", "code", "message"), + [ + ( + {"scenario": "unknown"}, + "SPM_SETTINGS_INVALID", + "Unknown forecast scenario: unknown", + ), + ( + {"county_vintage": "2010"}, + "SPM_SETTINGS_INVALID", + "Unsupported county vintage: use 2020", + ), + ( + {"geography_kind": "metro", "geography_id": "99999"}, + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM area is unavailable in the selected artifact: 99999", + ), + ], +) +def test_invalid_selections_use_public_api_codes( + certified_bundle, selection, code, message +) -> None: + error = _error(selection) + + assert (error.code, error.message) == (code, message) + + +def test_a_bundle_without_spm_keeps_legacy_behaviour_only_for_old_models( + monkeypatch, +) -> None: + monkeypatch.setattr(household_spm, "_bundle_spm_configuration", lambda: None) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: False) + + assert _resolve() is None + assert _error({}).code == "SPM_SETTINGS_UNSUPPORTED" + + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + assert _error().code == "SPM_CONFIGURATION_UNAVAILABLE" + + +def test_an_unreadable_bundle_manifest_fails_closed(monkeypatch) -> None: + def unreadable_bundle(): + msg = "bundle manifest is missing" + raise ValueError(msg) + + monkeypatch.setattr(household_spm, "_current_bundle", unreadable_bundle) + + error = _error() + + assert (error.code, error.message) == ( + "SPM_CONFIGURATION_UNAVAILABLE", + "The installed bundle manifest is invalid", + ) + + +def test_changing_the_geography_kind_drops_an_inherited_area(monkeypatch) -> None: + monkeypatch.setattr( + household_spm, + "_bundle_spm_configuration", + lambda: {**DEFAULTS, "geography_kind": "metro", "geography_id": "31080"}, + ) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + monkeypatch.setattr(household_spm, "_forecast", lambda sha: FakeForecast()) + + assert _resolve({"geography_kind": "national"}) == { + **DEFAULTS, + "geography_kind": "national", + } + assert _resolve()["geography_id"] == "31080" + + +def test_an_uncertifiable_bundle_configuration_fails_closed(monkeypatch) -> None: + monkeypatch.setattr( + household_spm, + "_bundle_spm_configuration", + lambda: {**DEFAULTS, "scenario": None}, + ) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + + assert _error().code == "SPM_CONFIGURATION_UNAVAILABLE" + + +class FakeSimulation: + def __init__(self, *, config=None, receipt=None): + self.spm_config = dict(DEFAULTS) if config is None else config + self.receipt = receipt + + def spm_provenance(self): + return self.receipt + + +def _country_receipt(**overrides) -> dict: + return { + "forecast_id": "spm-forecast-2026", + "forecast_sha256": FORECAST_SHA, + "scenario": "ce_trend", + "geography_kind": "county", + "years": {"2027": {}, "2026": {}}, + "runtime_versions": RUNTIME_VERSIONS, + "composition_method": "ignored by the compact receipt", + **overrides, + } + + +def test_the_country_receipt_becomes_the_compact_public_receipt() -> None: + provenance = household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt()), DEFAULTS + ) + + assert provenance is not None + assert provenance.model_dump(mode="json", by_alias=True) == { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2026", + "forecast_sha256": FORECAST_SHA, + "scenario": "ce_trend", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2026", "2027"], + "runtime_versions": RUNTIME_VERSIONS, + } + + +def test_a_country_without_spm_has_no_receipt() -> None: + assert household_spm.household_spm_provenance(object(), None) is None + + +def test_an_uncertifiable_country_receipt_is_an_spm_error() -> None: + with pytest.raises(SPMInputError) as raised: + household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt(scenario="flat")), DEFAULTS + ) + + assert raised.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert raised.value.message.startswith( + "The installed country model reported an SPM receipt this API cannot certify" + ) + + +def test_a_receipt_for_another_selection_is_rejected() -> None: + with pytest.raises(ValueError, match="differs from the request"): + household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt()), + {**DEFAULTS, "county_vintage": "2010"}, + ) + with pytest.raises(ValueError, match="without SPM"): + household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt()), None + ) + + +class CountryInputError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def test_spm_errors_are_recognized_through_wrapping_exceptions() -> None: + cause = CountryInputError("SPM_GEOGRAPHY_REQUIRED", "needs a county") + wrapped = RuntimeError("while calculating spm_unit_spm_threshold") + wrapped.__cause__ = cause + + detail = household_spm.household_spm_error_detail(wrapped) + + assert detail is not None + assert (detail.code, detail.message) == ("SPM_GEOGRAPHY_REQUIRED", "needs a county") + assert ( + household_spm.household_spm_error_detail( + SPMInputError("SPM_SETTINGS_INVALID", "bad") + ) + is not None + ) + assert ( + household_spm.household_spm_error_detail( + CountryInputError("SPM_SCENARIO_UNAVAILABLE", "no scenario") + ) + is None + ) + assert household_spm.household_spm_error_detail(ValueError("plain")) is None + + +def test_the_certified_forecast_is_loaded_once_by_its_digest() -> None: + from policyengine_simulation_executor.spm import certified_spm_forecast + + forecast = certified_spm_forecast(FORECAST_SHA) + + assert certified_spm_forecast(FORECAST_SHA) is forecast + assert forecast.years From bf9d7793c749f065aae1550a8ce03498addfc9d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:01:34 +0400 Subject: [PATCH 009/124] Register observability stages for the v2 household functions A v2 household function call is measured as one operation that covers the whole call, with nested spans for loading the country model, preparing the household, and calculating it under each policy. The stage plan lists those names so the household functions open spans only from registered stages. Co-Authored-By: Claude Opus 5.5 --- .../stages.py | 17 +++++++++++++++++ .../tests/test_stages.py | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py b/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py index aaa35eadd..14036f212 100644 --- a/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py +++ b/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py @@ -26,6 +26,7 @@ class RunConfiguration(StrEnum): STAGE12_CANONICAL_REPORT = "stage12_canonical_report" STAGE12_SIMULATION = "stage12_simulation" STAGE12_SEGMENT = "stage12_segment" + V2_HOUSEHOLD = "v2_household" class Stage(StrEnum): @@ -111,6 +112,10 @@ class Stage(StrEnum): STAGE12_AGGREGATION = "stage12_aggregation" STAGE12_AGGREGATE_ARTIFACT_WRITE = "stage12_aggregate_artifact_write" STAGE12_RESULT_COMPARISON = "stage12_result_comparison" + V2_HOUSEHOLD_EXECUTION = "v2_household_execution" + V2_HOUSEHOLD_COUNTRY_MODEL_LOAD = "v2_household_country_model_load" + V2_HOUSEHOLD_INPUT_NORMALIZATION = "v2_household_input_normalization" + V2_HOUSEHOLD_CALCULATION = "v2_household_calculation" @dataclass(frozen=True) @@ -271,6 +276,17 @@ def name(self, stage: Stage) -> str: Stage.STAGE12_CALCULATION, ), ), + # One v2 household function call: loading the country model, + # preparing the household, then one calculation per policy. + RunConfiguration.V2_HOUSEHOLD: StagePlan( + RunConfiguration.V2_HOUSEHOLD, + ( + Stage.V2_HOUSEHOLD_EXECUTION, + Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, + Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, + Stage.V2_HOUSEHOLD_CALCULATION, + ), + ), } ) @@ -285,3 +301,4 @@ def name(self, stage: Stage) -> str: ] STAGE12_SIMULATION_STAGES = RUN_STAGE_REGISTRY[RunConfiguration.STAGE12_SIMULATION] STAGE12_SEGMENT_STAGES = RUN_STAGE_REGISTRY[RunConfiguration.STAGE12_SEGMENT] +V2_HOUSEHOLD_STAGES = RUN_STAGE_REGISTRY[RunConfiguration.V2_HOUSEHOLD] diff --git a/libs/policyengine-simulation-observability/tests/test_stages.py b/libs/policyengine-simulation-observability/tests/test_stages.py index fbba9f721..0861363b9 100644 --- a/libs/policyengine-simulation-observability/tests/test_stages.py +++ b/libs/policyengine-simulation-observability/tests/test_stages.py @@ -54,3 +54,15 @@ def test_annual_status_polls_measure_the_job_progress_read() -> None: annual = RUN_STAGE_REGISTRY[RunConfiguration.ANNUAL_IMPACT] assert annual.name(Stage.MODAL_JOB_PROGRESS_READ) == "modal_job_progress_read" + + +def test_v2_household_plan_measures_model_load_inputs_and_each_policy() -> None: + household = RUN_STAGE_REGISTRY[RunConfiguration.V2_HOUSEHOLD] + + assert household.stages == ( + Stage.V2_HOUSEHOLD_EXECUTION, + Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, + Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, + Stage.V2_HOUSEHOLD_CALCULATION, + ) + assert household.name(Stage.V2_HOUSEHOLD_CALCULATION) == "v2_household_calculation" From d52d956f0eb1b9259aec1622e7c95a7dd365d29a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:27:01 +0400 Subject: [PATCH 010/124] Calculate a household under several policies as the Public API does calculate_household runs one household request: it checks the request names the installed bundle, resolves the SPM selection, prepares the household once, then builds a simulation and calculates the requested values for each policy in order. It returns each policy's calculated household, warnings and SPM receipt with the applied SPM selection and bundle provenance, or a typed failure. Each step follows the Public API's household calculation: reform values are converted to each parameter's value type, values are written as its country module writes them, a value that cannot be calculated is null (one null per point, with a warning, when the household has axes), and an SPM error ends the calculation only for a chosen measurement or a configuration the build cannot certify. Failures carry the Public API's messages: an invalid household payload, unrecognized inputs, an SPM error, or an error calculating the household under a policy. Country models are loaded once per process. The tests calculate real US and UK households, including US households with axes and with every missing variable added. Co-Authored-By: Claude Opus 5.5 --- .../household_calculation.py | 664 ++++++++++++++ .../tests/test_household_calculation.py | 858 ++++++++++++++++++ 2 files changed, 1522 insertions(+) create mode 100644 projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py create mode 100644 projects/policyengine-simulation-executor/tests/test_household_calculation.py diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py new file mode 100644 index 000000000..9a9b29c26 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -0,0 +1,664 @@ +"""Household calculations for the v2 Modal household functions. + +``calculate_household`` runs one ``HouseholdExecutionRequest``: it calculates +the household under each requested policy, in order, and returns a +``HouseholdExecutionResult`` or a typed ``HouseholdExecutionFailure``. + +The calculation reproduces the Public API's in-process household calculation +(its household calculation service, household routes and country module): the +same input preparation, simulation construction, reform value conversion, +requested values, value formatting, handling of values that cannot be +calculated, warnings and SPM receipt. Failures carry the codes and messages of +the Public API's matching error responses. This keeps v2 results comparable +with the Public API's results while both exist. + +Two values are written differently, because the contract carries only finite +numbers and JSON scalars: an undefined number is the string "NaN" (the Public +API writes a bare ``NaN``, which is not JSON), and a date is the HTTP date +text the Public API's Flask encoder writes. +""" + +from __future__ import annotations + +import importlib +import json +import logging +import math +from contextlib import AbstractContextManager, nullcontext +from dataclasses import dataclass, field +from datetime import UTC, date, datetime, time +from email.utils import format_datetime +from functools import lru_cache +from typing import TYPE_CHECKING, Any + +from policyengine_core.enums import Enum +from policyengine_core.errors import SituationParsingError +from policyengine_core.parameters import get_parameter +from policyengine_core.periods import period as parse_period +from policyengine_simulation_contract.household_execution import ( + CalculatedHousehold, + HouseholdBundleProvenance, + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdExecutionFailure, + HouseholdExecutionResult, + HouseholdPolicyResult, +) +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMInputError, + SPMResolvedConfiguration, +) +from policyengine_simulation_observability import scopes +from policyengine_simulation_observability.stages import V2_HOUSEHOLD_STAGES, Stage + +from policyengine_simulation_executor.household_inputs import ( + HouseholdModelMetadata, + RequestedValue, + add_missing_variables, + drop_deprecated_inputs, + find_unrecognized_household_inputs, + find_unrecognized_policy_inputs, + requested_computations, + unrecognized_inputs_message, +) +from policyengine_simulation_executor.household_spm import ( + HOUSEHOLD_SPM_INPUT_ERROR_CODES, + household_spm_error_detail, + household_spm_provenance, + resolve_household_spm_selection, +) +from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from policyengine_core.periods import Period + from policyengine_core.simulations import Simulation + from policyengine_core.taxbenefitsystems import TaxBenefitSystem + from policyengine_observability import ObservabilityRuntime + from policyengine_simulation_contract.household_execution import ( + HouseholdErrorCode, + HouseholdExecutionRequest, + HouseholdPolicy, + HouseholdWorkerIdentity, + ) + from policyengine_simulation_contract.spm import SPMProvenance + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CountryHouseholdModel: + """A loaded country model: its ``Simulation`` type, tax-benefit system and metadata. + + The tax-benefit system supplies the metadata and the value types of + reform parameters; simulations build their own, as in the Public API. + """ + + country: str + simulation_type: Callable[..., Simulation] + tax_benefit_system: TaxBenefitSystem + metadata: HouseholdModelMetadata + + +@lru_cache(maxsize=4) +def load_country_household_model(country: str) -> CountryHouseholdModel: + """Import ``country``'s model package and build its tax-benefit system once. + + The package is the one the installed PolicyEngine.py bundle names for the + country. The result is kept for the life of the process, so warm Modal + containers reuse it. Raises ValueError for a country the bundle lacks. + """ + bundle = load_stage12_bundle().bundle + country_bundle = next( + (item for item in bundle.countries if item.country == country), None + ) + if country_bundle is None: + msg = f"the installed bundle does not carry country {country!r}" + raise ValueError(msg) + package = importlib.import_module( + country_bundle.country_package_name.replace("-", "_") + ) + system = package.CountryTaxBenefitSystem() + return CountryHouseholdModel( + country=country, + simulation_type=package.Simulation, + tax_benefit_system=system, + metadata=HouseholdModelMetadata.from_tax_benefit_system(system), + ) + + +def installed_household_bundle(country: str) -> HouseholdBundleProvenance: + """Return the provenance of the installed bundle for ``country``.""" + resolved = load_stage12_bundle() + return HouseholdBundleProvenance.from_bundle( + resolved.bundle, + bundle_manifest_sha256=resolved.bundle_manifest_sha256, + country=country, + ) + + +@dataclass(frozen=True) +class HouseholdCalculationDependencies: + """Seams a household calculation uses; unset seams use the defaults. + + The defaults are the installed bundle, the process's cached country + models, today's UTC date (which sets the default household year) and no + telemetry runtime. + """ + + installed_bundle: HouseholdBundleProvenance | None = None + model_loader: Callable[[str], CountryHouseholdModel] = load_country_household_model + today: date | None = None + runtime: ObservabilityRuntime | None = None + + +class _CalculationFailedError(Exception): + """A household calculation ended with a typed error.""" + + def __init__(self, error: HouseholdCalculationError) -> None: + super().__init__(error.message) + self.error = error + + +def _failure( + code: HouseholdErrorCode, + message: str, + *, + policy_key: str | None = None, +) -> _CalculationFailedError: + return _CalculationFailedError( + HouseholdCalculationError(code=code, message=message, policy_key=policy_key) + ) + + +def _spm_failure( + error: BaseException, *, policy_key: str | None +) -> _CalculationFailedError | None: + detail = household_spm_error_detail(error) + if detail is None: + return None + return _CalculationFailedError( + HouseholdCalculationError( + code="invalid_spm_selection", + message=detail.message, + spm_error=detail, + policy_key=policy_key, + ) + ) + + +def _invalid_household_failure( + error: SituationParsingError, *, policy_key: str +) -> _CalculationFailedError: + """Report a household the country model cannot read, as the Public API does.""" + return _failure( + "invalid_household", + f"Invalid household payload: {error}", + policy_key=policy_key, + ) + + +def _bundle_mismatch_failure( + bundle: HouseholdBundleProvenance, +) -> _CalculationFailedError: + """Report a request for a bundle other than ``bundle``, the one installed.""" + return _failure( + "bundle_mismatch", + "This household function runs PolicyEngine.py " + f"{bundle.policyengine_version} with {bundle.country_package_name} " + f"{bundle.country_package_version} " + f"(bundle {bundle.bundle_manifest_sha256}), not the requested bundle", + ) + + +def _unexpected_failure( + error: BaseException, *, policy_key: str +) -> _CalculationFailedError: + """Report an SPM error as such, and anything else as a failed calculation.""" + failure = _spm_failure(error, policy_key=policy_key) + if failure is not None: + return failure + logger.error( + "Error calculating household under policy %s", policy_key, exc_info=error + ) + return _failure( + "calculation_failed", + f"Error calculating household under policy: {error}", + policy_key=policy_key, + ) + + +def _stage_scope( + runtime: ObservabilityRuntime | None, + stage: Stage, + attributes: Mapping[str, object], +) -> AbstractContextManager[object]: + if runtime is None: + return nullcontext() + return scopes.span( + runtime, V2_HOUSEHOLD_STAGES.name(stage), attributes=dict(attributes) + ) + + +# Value formatting, as the Public API's country module and JSON encoder +# write values. + + +def _serialize_float(value: object) -> float | str: + """Write a number as the Public API does, naming non-finite values.""" + serialized = float(str(value)) + if math.isinf(serialized): + return "Infinity" if serialized > 0 else "-Infinity" + if math.isnan(serialized): + return "NaN" + return serialized + + +def _serialize_scalar(value: object) -> object: + """Write a date as the HTTP date text the Public API's Flask encoder writes.""" + if isinstance(value, datetime): + moment = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return format_datetime(moment.astimezone(UTC), usegmt=True) + if isinstance(value, date): + return format_datetime(datetime.combine(value, time(), tzinfo=UTC), usegmt=True) + return value + + +def _serialize_axis_values( + result: Any, # noqa: ANN401 # a NumPy array or EnumArray from the country model + value_type: type, + entity_index: int, + entity_count: int, +) -> list[object]: + if value_type == Enum: + result = result.decode_to_str() + entity_values: list[object] = ( + result.reshape((-1, entity_count)).T[entity_index].tolist() + ) + if value_type is float: + return [_serialize_float(value) for value in entity_values] + if value_type is str: + return [str(value) for value in entity_values] + return [_serialize_scalar(value) for value in entity_values] + + +def _serialize_entity_value( + result: Any, # noqa: ANN401 # a NumPy array or EnumArray from the country model + value_type: type, + entity_index: int, +) -> object: + if value_type == Enum: + return result.decode()[entity_index].name + if value_type is float: + return _serialize_float(result[entity_index]) + if value_type is str: + return str(result[entity_index]) + return _serialize_scalar(result.tolist()[entity_index]) + + +@dataclass(frozen=True) +class _PreparedHousehold: + """A household ready to calculate under each policy. + + ``situation`` has its missing variables added and deprecated inputs + removed; ``input_warnings`` say which inputs were removed. + ``spm_selection`` is the resolved SPM selection (None when no SPM + applies) and ``spm_requested`` whether the caller chose it. + """ + + situation: dict[str, Any] + input_warnings: tuple[str, ...] + spm_selection: dict[str, Any] | None + spm_requested: bool + + +def _normalized_reform( + reform: Mapping[str, Mapping[str, object]], + system: TaxBenefitSystem, +) -> dict[str, dict[str, object]]: + """Convert reform values to each parameter's current value type. + + Integers become floats, and numeric text becomes a number first, as in the + Public API. + """ + root = system.parameters + if root is None: + msg = "the country model has no parameters" + raise LookupError(msg) + normalized: dict[str, dict[str, object]] = {} + for parameter_name, parameter_updates in reform.items(): + parameter = get_parameter(root, parameter_name) + convert = _reform_value_type(parameter.values_list[-1].value) + normalized[parameter_name] = { + time_period: convert(_numeric_if_possible(value)) + for time_period, value in parameter_updates.items() + } + return normalized + + +def _reform_value_type(current_value: object) -> Callable[[Any], object]: + """Return the type a reform value becomes: the current value's, int as float.""" + value_type = type(current_value) + return float if value_type is int else value_type + + +def _numeric_if_possible(value: object) -> object: + """Return ``value`` as a float when it reads as one, as the Public API does.""" + if not isinstance(value, (bool, int, float, str)): + return value + try: + return float(value) + except (ValueError, OverflowError): + return value + + +def _create_simulation( + model: CountryHouseholdModel, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> Simulation: + """Build the country model's simulation of the household under ``policy``. + + The country ``Simulation`` takes the reform as converted parameter values + and the resolved SPM selection, as the Public API passes them. + """ + options: dict[str, object] = { + "situation": json.loads(json.dumps(prepared.situation)) + } + if prepared.spm_selection is not None: + options["spm"] = dict(prepared.spm_selection) + if policy.parameters: + options["reform"] = _normalized_reform( + policy.parameters, model.tax_benefit_system + ) + return model.simulation_type(**options) + + +def _model_period(text: str) -> Period: + """Read a household period key as policyengine-core's ``calculate`` reads it. + + Raises ValueError for text that names no period. + """ + parsed = parse_period(text) + if parsed is None: + msg = f"{text!r} names no period" + raise ValueError(msg) + return parsed + + +@dataclass +class _PolicyCalculation: + """One policy's simulation and the household its values are written into.""" + + simulation: Simulation + household: dict[str, Any] + spm_requested: bool + warnings: list[str] = field(default_factory=list) + + @property + def has_axes(self) -> bool: + """Whether the household is calculated at several axis points.""" + return "axes" in self.household + + def calculate_all(self) -> None: + """Calculate every requested value, as the Public API's country module does.""" + system = self.simulation.tax_benefit_system + computations = requested_computations( + self.household, + include_provided_values=self.has_axes, + variable_names=set(system.variables) if self.has_axes else None, + ) + for requested in computations: + try: + self._calculate_value(requested) + except Exception as error: + detail = household_spm_error_detail(error) + # A chosen measurement reports its missing inputs. An + # inherited one leaves its dependants uncalculated, but only + # for a missing input: a configuration failure always ends + # the calculation. + if detail is not None and ( + self.spm_requested + or detail.code not in HOUSEHOLD_SPM_INPUT_ERROR_CODES + ): + raise + self._record_failure(requested, error) + + def _calculate_value(self, requested: RequestedValue) -> None: + entity_plural, entity_id, variable_name, period = requested + population = self.simulation.get_population(entity_plural) + variable = self.simulation.tax_benefit_system.get_variable(variable_name) + if variable is None: + msg = f"the country model has no variable {variable_name}" + raise LookupError(msg) + value_type = variable.value_type + result = self.simulation.calculate(variable_name, _model_period(period)) + entities = self.household[entity_plural] + if self.has_axes: + value: object = _serialize_axis_values( + result, value_type, list(entities).index(entity_id), len(entities) + ) + else: + value = _serialize_entity_value( + result, value_type, population.get_index(entity_id) + ) + entities[entity_id][variable_name][period] = value + + def _record_failure(self, requested: RequestedValue, error: Exception) -> None: + entity_plural, entity_id, variable_name, period = requested + cells = self.household[entity_plural][entity_id][variable_name] + if not self.has_axes: + cells[period] = None + logger.warning( + "Unable to calculate %s for %s in %s: %s", + variable_name, + entity_id, + period, + error, + ) + return + population = self.simulation.get_population(entity_plural) + axis_point_count = population.count // len(self.household[entity_plural]) + cells[period] = [None] * axis_point_count + warning = ( + f"Unable to calculate {variable_name} for {entity_id} in " + f"{period}; returned {axis_point_count} null axis values: {error}" + ) + self.warnings.append(warning) + logger.warning("%s", warning) + + +def _parsed_policy_calculation( + model: CountryHouseholdModel, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> _PolicyCalculation: + """Build the policy's simulation, failing as the Public API's parse stage does.""" + try: + simulation = _create_simulation(model, prepared, policy) + except SituationParsingError as error: + raise _invalid_household_failure(error, policy_key=policy.policy_key) from error + except Exception as error: + raise _unexpected_failure(error, policy_key=policy.policy_key) from error + return _PolicyCalculation( + simulation=simulation, + household=json.loads(json.dumps(prepared.situation)), + spm_requested=prepared.spm_requested, + ) + + +def _calculated_policy_result( + calculation: _PolicyCalculation, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> HouseholdPolicyResult: + """Calculate the requested values and return the policy's result.""" + calculation.calculate_all() + spm_provenance: SPMProvenance | None = household_spm_provenance( + calculation.simulation, prepared.spm_selection + ) + return HouseholdPolicyResult( + policy_key=policy.policy_key, + household=CalculatedHousehold.model_validate(calculation.household), + warnings=(*prepared.input_warnings, *calculation.warnings), + spm_provenance=spm_provenance, + ) + + +def _calculate_policy( + model: CountryHouseholdModel, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> HouseholdPolicyResult: + """Calculate the household under one policy, raising a typed failure.""" + calculation = _parsed_policy_calculation(model, prepared, policy) + try: + return _calculated_policy_result(calculation, prepared, policy) + except SituationParsingError as error: + failure = _spm_failure( + error, policy_key=policy.policy_key + ) or _invalid_household_failure(error, policy_key=policy.policy_key) + raise failure from error + except Exception as error: + raise _unexpected_failure(error, policy_key=policy.policy_key) from error + + +def _resolved_spm_selection( + request: HouseholdExecutionRequest, +) -> dict[str, Any] | None: + try: + return resolve_household_spm_selection( + request.country, + request.spm, + saved_household=request.spm_selection_source == "saved_household", + ) + except SPMInputError as error: + raise _CalculationFailedError( + HouseholdCalculationError( + code="invalid_spm_selection", + message=error.message, + spm_error=SPMErrorDetail(code=error.code, message=error.message), + ) + ) from error + + +def _prepared_household( + request: HouseholdExecutionRequest, + model: CountryHouseholdModel, + *, + today: date, + spm_selection: dict[str, Any] | None, +) -> _PreparedHousehold: + """Prepare the household as the Public API does; raise for unknown names. + + Unknown household names are reported with the first policy, as the Public + API's baseline request would report them; unknown policy parameters are + reported with the policy that names them. + """ + situation = request.household.to_situation() + if request.add_missing_variables: + add_missing_variables(situation, model.metadata, today=today) + deprecated = drop_deprecated_inputs(situation) + household_errors = find_unrecognized_household_inputs( + deprecated.household, model.metadata + ) + for policy in request.policies: + policy_errors = find_unrecognized_policy_inputs( + policy.parameters, model.metadata + ) + input_errors = (*household_errors, *policy_errors) + if input_errors: + raise _CalculationFailedError( + HouseholdCalculationError( + code="invalid_inputs", + message=unrecognized_inputs_message(input_errors), + input_errors=input_errors, + policy_key=policy.policy_key if policy_errors else None, + ) + ) + return _PreparedHousehold( + situation=deprecated.household, + input_warnings=tuple(warning.message for warning in deprecated.warnings), + spm_selection=spm_selection, + spm_requested=request.spm_requested, + ) + + +def _calculate_output( + request: HouseholdExecutionRequest, + bundle: HouseholdBundleProvenance, + dependencies: HouseholdCalculationDependencies, +) -> HouseholdCalculationOutput: + """Calculate every policy of ``request``, raising a typed failure. + + Checks run in the Public API's order: the bundle, the SPM selection, the + household's and policies' names, then each policy. + """ + if request.bundle != bundle: + raise _bundle_mismatch_failure(bundle) + spm_selection = _resolved_spm_selection(request) + country = {"country": request.country} + runtime = dependencies.runtime + with _stage_scope(runtime, Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, country): + model = dependencies.model_loader(request.country) + with _stage_scope(runtime, Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, country): + prepared = _prepared_household( + request, + model, + today=dependencies.today or datetime.now(UTC).date(), + spm_selection=spm_selection, + ) + results = [] + for policy in request.policies: + attributes = {**country, "policy_key": policy.policy_key} + with _stage_scope(runtime, Stage.V2_HOUSEHOLD_CALCULATION, attributes): + results.append(_calculate_policy(model, prepared, policy)) + return HouseholdCalculationOutput( + country=request.country, + bundle=bundle, + spm_config=( + SPMResolvedConfiguration.model_validate(spm_selection) + if spm_selection is not None + else None + ), + results=tuple(results), + ) + + +def calculate_household( + request: HouseholdExecutionRequest, + *, + worker: HouseholdWorkerIdentity, + required_country: str | None = None, + dependencies: HouseholdCalculationDependencies | None = None, +) -> HouseholdExecutionResult | HouseholdExecutionFailure: + """Calculate one household under each requested policy. + + Returns the per-policy results, or a typed failure for a request for + another bundle, an unusable SPM selection, unrecognized inputs, a household + the country model cannot build, or any other calculation error. Raises + ValueError when ``required_country`` is set and the request names another + country; a household function calculates its own country only. + """ + if required_country is not None and request.country != required_country: + msg = f"this function calculates {required_country} households only" + raise ValueError(msg) + dependencies = dependencies or HouseholdCalculationDependencies() + bundle = dependencies.installed_bundle or installed_household_bundle( + request.country + ) + try: + output = _calculate_output(request, bundle, dependencies) + except _CalculationFailedError as failure: + return HouseholdExecutionFailure( + calculation_id=request.calculation_id, + error=failure.error, + worker=worker, + ) + return HouseholdExecutionResult( + calculation_id=request.calculation_id, + output=output, + worker=worker, + ) diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py new file mode 100644 index 000000000..c6874b31e --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -0,0 +1,858 @@ +"""Tests for v2 household calculations, ported from the Public API's behaviour. + +The first tests use a stand-in country model to cover the failure paths. The +later ones calculate with the installed US and UK models, as the Public API's +in-process calculation does; the US model takes about a minute to load once. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from datetime import date +from types import SimpleNamespace +from uuid import UUID + +import numpy as np +import pytest +from policyengine_core.errors import SituationParsingError +from policyengine_simulation_contract.household_execution import ( + HouseholdBundleProvenance, + HouseholdExecutionFailure, + HouseholdExecutionRequest, + HouseholdExecutionResult, + HouseholdWorkerIdentity, +) +from policyengine_simulation_contract.spm import SPMInputError +from policyengine_simulation_executor import household_calculation +from policyengine_simulation_executor.household_calculation import ( + CountryHouseholdModel, + HouseholdCalculationDependencies, + calculate_household, + installed_household_bundle, + load_country_household_model, +) +from policyengine_simulation_executor.household_inputs import ( + HouseholdEntity, + HouseholdModelMetadata, + HouseholdVariable, +) + +CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") +TODAY = date(2026, 6, 1) + + +def _worker(country: str) -> HouseholdWorkerIdentity: + return HouseholdWorkerIdentity( + application_name="policyengine-simulation-v2-py6-7-5", + function_name=f"calculate_household_{country}", + ) + + +def _request(country: str, household: dict, **overrides) -> HouseholdExecutionRequest: + values = { + "calculation_id": CALCULATION_ID, + "country": country, + "household": household, + "policies": [{"policy_key": "baseline", "parameters": {}}], + "bundle": installed_household_bundle(country).model_dump(mode="json"), + } + values.update(overrides) + return HouseholdExecutionRequest.model_validate(values) + + +class RecordingRuntime: + """Records the spans a calculation opens, in order.""" + + def __init__(self) -> None: + self.spans: list[tuple[str, dict]] = [] + + @contextmanager + def span(self, name: str, *, attributes: dict | None = None): + self.spans.append((name, dict(attributes or {}))) + yield + + +# A stand-in country model: enough of a country package to exercise the +# failure paths without importing a real one. + + +class StandInPopulation: + def __init__(self, ids: list[str], count: int) -> None: + self.ids = ids + self.count = count + + def get_index(self, entity_id: str) -> int: + return self.ids.index(entity_id) + + +class StandInSystem: + def __init__(self, value_types: dict[str, type]) -> None: + self.value_types = value_types + self.variables = dict.fromkeys(value_types) + + def get_variable(self, name: str): + return SimpleNamespace(value_type=self.value_types[name]) + + +STAND_IN_VALUE_TYPES = { + "age": int, + "net_income": float, + "spm_resources": float, + "registration_date": date, +} + + +def _stand_in_model(simulation_type) -> CountryHouseholdModel: + metadata = HouseholdModelMetadata( + variables={ + name: HouseholdVariable( + name, + "person" if name == "age" else "household", + "year", + is_input=name == "age", + default_value=40 if name == "age" else None, + ) + for name in STAND_IN_VALUE_TYPES + }, + entities={ + "person": HouseholdEntity("person", "people", frozenset()), + "household": HouseholdEntity( + "household", "households", frozenset({"members"}) + ), + }, + parameters=frozenset(), + ) + system = StandInSystem(STAND_IN_VALUE_TYPES) + simulation_type.tax_benefit_system = system + return CountryHouseholdModel( + country="uk", + simulation_type=simulation_type, + tax_benefit_system=system, + metadata=metadata, + ) + + +class CountryInputError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def _stand_in_simulation(outcomes: dict[str, object], *, axis_points: int = 1): + class StandInSimulation: + tax_benefit_system: StandInSystem + + def __init__(self, *, situation: dict, **options: object) -> None: + self.situation = situation + self.options = options + people = list(situation["people"]) + households = list(situation.get("households", {})) + self.populations = { + "people": StandInPopulation(people, len(people) * axis_points), + "households": StandInPopulation( + households, len(households) * axis_points + ), + } + + def get_population(self, plural: str) -> StandInPopulation: + return self.populations[plural] + + def calculate(self, variable_name: str, period: str): + outcome = outcomes[variable_name] + if isinstance(outcome, Exception): + raise outcome + return outcome + + return StandInSimulation + + +STAND_IN_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 30}}}, + "households": { + "home": { + "members": ["you"], + "net_income": {"2026": None}, + "spm_resources": {"2026": None}, + } + }, +} + + +def _calculate_with_stand_in( + simulation_type, household=None, *, country="uk", **overrides +): + model = _stand_in_model(simulation_type) + return calculate_household( + _request(country, household or STAND_IN_HOUSEHOLD, **overrides), + worker=_worker(country), + dependencies=HouseholdCalculationDependencies( + model_loader=lambda _country: model, today=TODAY + ), + ) + + +def test_a_value_that_cannot_be_calculated_is_null_without_a_warning() -> None: + outcome = _calculate_with_stand_in( + _stand_in_simulation( + {"net_income": np.array([1.5]), "spm_resources": KeyError("missing")} + ) + ) + + assert isinstance(outcome, HouseholdExecutionResult) + result = outcome.output.results[0] + home = result.household.to_situation()["households"]["home"] + assert home["net_income"] == {"2026": 1.5} + assert home["spm_resources"] == {"2026": None} + assert result.warnings == () + assert outcome.output.spm_config is None + assert result.spm_provenance is None + + +def test_with_axes_a_failed_value_is_one_null_per_point_with_a_warning() -> None: + household = { + **STAND_IN_HOUSEHOLD, + "axes": [[{"name": "age", "count": 3, "min": 0, "max": 2}]], + } + + outcome = _calculate_with_stand_in( + _stand_in_simulation( + { + "age": np.array([0, 1, 2]), + "net_income": np.array([1.0, float("inf"), float("nan")]), + "spm_resources": KeyError("missing"), + }, + axis_points=3, + ), + household, + ) + + assert isinstance(outcome, HouseholdExecutionResult) + result = outcome.output.results[0] + situation = result.household.to_situation() + assert situation["people"]["you"]["age"] == {"2026": [0, 1, 2]} + assert situation["households"]["home"]["net_income"] == { + "2026": [1.0, "Infinity", "NaN"] + } + assert situation["households"]["home"]["spm_resources"] == { + "2026": [None, None, None] + } + assert situation["axes"] == household["axes"] + assert result.warnings == ( + "Unable to calculate spm_resources for home in 2026; returned 3 null " + "axis values: 'missing'", + ) + + +def test_dates_are_written_as_the_public_api_writes_them() -> None: + # The Public API's Flask JSON encoder writes a date as an HTTP date. + household = { + "people": {"you": {"age": {"2026": 30}}}, + "households": { + "home": {"members": ["you"], "registration_date": {"2026": None}} + }, + } + + outcome = _calculate_with_stand_in( + _stand_in_simulation( + {"registration_date": np.array(["1970-01-01"], dtype="datetime64[D]")} + ), + household, + ) + + assert isinstance(outcome, HouseholdExecutionResult) + home = outcome.output.results[0].household.to_situation()["households"]["home"] + assert home["registration_date"] == {"2026": "Thu, 01 Jan 1970 00:00:00 GMT"} + + +def test_an_spm_input_error_fails_only_a_chosen_measurement(monkeypatch) -> None: + error = CountryInputError("SPM_GEOGRAPHY_REQUIRED", "SPM needs a county") + outcomes = {"net_income": np.array([1.0]), "spm_resources": error} + resolved = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + monkeypatch.setattr( + household_calculation, + "resolve_household_spm_selection", + lambda country, selection, *, saved_household: dict(resolved), + ) + monkeypatch.setattr( + household_calculation, + "household_spm_provenance", + lambda simulation, expected_selection: None, + ) + + inherited = _calculate_with_stand_in(_stand_in_simulation(outcomes), country="us") + chosen = _calculate_with_stand_in( + _stand_in_simulation(outcomes), country="us", spm={} + ) + + assert isinstance(inherited, HouseholdExecutionResult) + home = inherited.output.results[0].household.to_situation()["households"]["home"] + assert home["spm_resources"] == {"2026": None} + assert inherited.output.spm_config is not None + assert inherited.output.spm_config.model_dump(mode="json") == resolved + assert isinstance(chosen, HouseholdExecutionFailure) + assert chosen.error.code == "invalid_spm_selection" + assert chosen.error.spm_error is not None + assert chosen.error.spm_error.code == "SPM_GEOGRAPHY_REQUIRED" + assert chosen.error.message == "SPM needs a county" + assert chosen.error.policy_key == "baseline" + + +def test_the_selection_and_its_receipt_reach_each_simulation(monkeypatch) -> None: + resolved = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + created: list[dict] = [] + receipts: list[tuple[object, object]] = [] + + class RecordingSimulation(_stand_in_simulation({"net_income": np.array([1.0])})): + def __init__(self, **options: object) -> None: + super().__init__(**options) + created.append(self.options) + + monkeypatch.setattr( + household_calculation, + "resolve_household_spm_selection", + lambda country, selection, *, saved_household: resolved, + ) + + def record_receipt(simulation, expected_selection): + receipts.append((simulation, expected_selection)) + + monkeypatch.setattr( + household_calculation, "household_spm_provenance", record_receipt + ) + household = { + "people": {"you": {"age": {"2026": 30}}}, + "households": {"home": {"members": ["you"], "net_income": {"2026": None}}}, + } + + _calculate_with_stand_in(RecordingSimulation, household, country="us") + + assert [options["spm"] for options in created] == [resolved] + assert [expected for _, expected in receipts] == [resolved] + + +def test_an_spm_configuration_error_fails_even_an_inherited_measurement( + monkeypatch, +) -> None: + # Only a missing SPM input leaves an inherited measurement's value null; + # a configuration the build cannot certify always ends the calculation. + error = SPMInputError("SPM_CONFIGURATION_UNAVAILABLE", "no certified artifact") + monkeypatch.setattr( + household_calculation, + "resolve_household_spm_selection", + lambda country, selection, *, saved_household: { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + }, + ) + + outcome = _calculate_with_stand_in( + _stand_in_simulation({"net_income": np.array([1.0]), "spm_resources": error}), + country="us", + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.spm_error is not None + assert outcome.error.spm_error.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert outcome.error.message == "no certified artifact" + + +@pytest.mark.parametrize( + ("simulation_error", "code", "message"), + [ + ( + SituationParsingError(["people"], "unknown person `ghost`"), + "invalid_household", + "Invalid household payload: ", + ), + ( + RuntimeError("model exploded"), + "calculation_failed", + "Error calculating household under policy: model exploded", + ), + ( + SPMInputError("SPM_SETTINGS_INVALID", "bad SPM settings"), + "invalid_spm_selection", + "bad SPM settings", + ), + ], +) +def test_simulation_build_failures_use_public_api_messages( + simulation_error: Exception, code: str, message: str +) -> None: + def failing_simulation(**_: object): + raise simulation_error + + outcome = _calculate_with_stand_in(failing_simulation) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == code + assert outcome.error.message.startswith(message) + assert outcome.error.policy_key == "baseline" + assert outcome.worker == _worker("uk") + assert outcome.calculation_id == CALCULATION_ID + + +def test_a_receipt_that_cannot_be_certified_is_an_spm_failure(monkeypatch) -> None: + def uncertifiable(simulation, expected_selection): + code = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMInputError(code, "cannot certify") + + monkeypatch.setattr( + household_calculation, "household_spm_provenance", uncertifiable + ) + + outcome = _calculate_with_stand_in( + _stand_in_simulation( + {"net_income": np.array([1.0]), "spm_resources": np.array([1.0])} + ) + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.message == "cannot certify" + + +def test_unrecognized_inputs_are_listed_in_the_order_the_household_was_sent() -> None: + # As the Public API lists them: it walks the household as sent, here with + # ``axes`` first, so the axis error comes before the person's. + household = { + "axes": [[{"name": "not_an_axis_variable", "count": 2, "min": 0, "max": 1}]], + "people": {"you": {"age": {"2026": 30}, "not_a_variable": {"2026": 1}}}, + "households": {"home": {"members": ["you"], "net_income": {"2026": None}}}, + } + + outcome = _calculate_with_stand_in(_stand_in_simulation({}), household) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_inputs" + assert [error.path for error in outcome.error.input_errors] == [ + "household.axes[0][0].name", + "household.people.you.not_a_variable", + ] + assert outcome.error.message == ( + "Unrecognized calculate input(s): Unrecognized household axis variable " + "`not_an_axis_variable` at `household.axes[0][0].name`.; Unrecognized " + "household variable `not_a_variable` at " + "`household.people.you.not_a_variable`." + ) + + +def test_a_request_for_another_bundle_is_refused() -> None: + bundle = installed_household_bundle("uk").model_copy( + update={"policyengine_version": "0.0.1"} + ) + + outcome = _calculate_with_stand_in( + _stand_in_simulation({}), bundle=bundle.model_dump(mode="json") + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "bundle_mismatch" + assert outcome.error.policy_key is None + + +def test_a_function_refuses_another_countrys_household() -> None: + model = _stand_in_model(_stand_in_simulation({})) + + with pytest.raises(ValueError, match="calculates us households only"): + calculate_household( + _request("uk", STAND_IN_HOUSEHOLD), + worker=_worker("us"), + required_country="us", + dependencies=HouseholdCalculationDependencies( + model_loader=lambda _country: model + ), + ) + + +def test_the_installed_bundle_names_the_country_package() -> None: + bundle = installed_household_bundle("us") + + assert isinstance(bundle, HouseholdBundleProvenance) + assert bundle.country_package_name == "policyengine-us" + + +# Installed country models: real calculations, compared with the Public API's +# result shape. + +US_FAMILY = { + "people": { + "you": {"age": {"2026": 35}, "employment_income": {"2026": 30_000}}, + "your first dependent": {"age": {"2026": 5}}, + }, + "tax_units": { + "your tax unit": { + "members": ["you", "your first dependent"], + "ctc": {"2026": None}, + } + }, + "spm_units": {"your spm unit": {"members": ["you", "your first dependent"]}}, + "families": {"your family": {"members": ["you", "your first dependent"]}}, + "marital_units": { + "your marital unit": {"members": ["you"]}, + "your first dependent's marital unit": {"members": ["your first dependent"]}, + }, + "households": { + "your household": { + "members": ["you", "your first dependent"], + "state_name": {"2026": "TX"}, + "county_fips": {"2026": "48201"}, + "household_net_income": {"2026": None}, + } + }, +} +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 5_000} +} + + +def test_us_baseline_and_reform_are_calculated_in_one_request() -> None: + runtime = RecordingRuntime() + request = _request( + "us", + US_FAMILY, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": CTC_REFORM}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(today=TODAY, runtime=runtime), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + assert outcome.calculation_id == CALCULATION_ID + assert outcome.output.bundle == installed_household_bundle("us") + baseline, reform = outcome.output.results + assert (baseline.policy_key, reform.policy_key) == ("baseline", "reform") + baseline_situation = baseline.household.to_situation() + reform_situation = reform.household.to_situation() + # Same structure as the input, with each requested value filled in. + assert list(baseline_situation) == list(US_FAMILY) + assert baseline_situation["people"] == US_FAMILY["people"] + baseline_ctc = baseline_situation["tax_units"]["your tax unit"]["ctc"]["2026"] + reform_ctc = reform_situation["tax_units"]["your tax unit"]["ctc"]["2026"] + assert isinstance(baseline_ctc, float) + assert isinstance(reform_ctc, float) + assert reform_ctc > baseline_ctc + # A US household inherits the certified SPM selection and carries its receipt. + assert outcome.output.spm_config is not None + assert outcome.output.spm_config.geography_kind == "county" + assert baseline.spm_provenance is not None + assert reform.spm_provenance is not None + assert baseline.spm_provenance.scenario == outcome.output.spm_config.scenario + assert [name for name, _ in runtime.spans] == [ + "v2_household_country_model_load", + "v2_household_input_normalization", + "v2_household_calculation", + "v2_household_calculation", + ] + assert runtime.spans[3][1] == {"country": "us", "policy_key": "reform"} + + +def test_us_axes_calculate_one_value_per_point() -> None: + household = { + **US_FAMILY, + "axes": [ + [ + { + "name": "employment_income", + "count": 5, + "min": 0, + "max": 80_000, + "period": "2026", + } + ] + ], + } + + outcome = calculate_household( + _request("us", household), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + incomes = situation["households"]["your household"]["household_net_income"] + assert len(incomes["2026"]) == 5 + assert all(isinstance(income, float) for income in incomes["2026"]) + assert incomes["2026"][0] < incomes["2026"][4] + # With axes, every value of a known variable is calculated at each point, + # and the people's provided values are the axis values. + assert situation["people"]["you"]["employment_income"] == { + "2026": [0.0, 20_000.0, 40_000.0, 60_000.0, 80_000.0] + } + assert situation["people"]["you"]["age"] == {"2026": [35.0] * 5} + assert situation["households"]["your household"]["state_name"] == { + "2026": ["TX"] * 5 + } + assert situation["axes"] == household["axes"] + + +def test_us_calculate_full_adds_and_calculates_every_missing_variable() -> None: + outcome = calculate_household( + _request("us", US_FAMILY, add_missing_variables=True), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + you = situation["people"]["you"] + tax_unit = situation["tax_units"]["your tax unit"] + model = load_country_household_model("us") + person_variables = { + variable.name + for variable in model.metadata.variables.values() + if variable.entity == "person" + and variable.definition_period in {"year", "month", "eternity"} + } + assert person_variables <= set(you) + assert isinstance(you["is_blind"]["2026"], bool) + assert isinstance(tax_unit["income_tax"]["2026"], float) + assert isinstance(tax_unit["eitc"]["2026"], float) + assert isinstance( + situation["households"]["your household"]["household_net_income"]["2026"], + float, + ) + + +UK_SINGLE = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, + "benunits": {"your benefit unit": {"members": ["you"]}}, + "households": { + "your household": {"members": ["you"], "household_net_income": {"2026": None}} + }, +} + + +def test_uk_axes_calculate_one_value_per_point() -> None: + household = { + **UK_SINGLE, + "axes": [ + [ + { + "name": "employment_income", + "count": 3, + "min": 0, + "max": 60_000, + "period": "2026", + } + ] + ], + } + + outcome = calculate_household( + _request("uk", household), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + incomes = situation["households"]["your household"]["household_net_income"] + assert len(incomes["2026"]) == 3 + assert incomes["2026"][0] < incomes["2026"][2] + assert situation["people"]["you"]["employment_income"] == { + "2026": [0.0, 30_000.0, 60_000.0] + } + assert outcome.output.results[0].spm_provenance is None + assert outcome.output.spm_config is None + + +def test_uk_calculate_full_adds_every_missing_yearly_variable() -> None: + outcome = calculate_household( + _request("uk", UK_SINGLE, add_missing_variables=True), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + you = outcome.output.results[0].household.to_situation()["people"]["you"] + assert isinstance(you["income_tax"]["2026"], float) + + +def test_uk_unrecognized_inputs_fail_with_public_api_errors() -> None: + household = { + **UK_SINGLE, + "people": {"you": {**UK_SINGLE["people"]["you"], "not_a_variable": 1}}, + } + request = _request( + "uk", + household, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": {"gov.not.a.parameter": {}}}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + # The first policy's request already fails on the household's own input, + # as the Public API's baseline request would. + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_inputs" + assert outcome.error.message == ( + "Unrecognized calculate input(s): Unrecognized household variable " + "`not_a_variable` at `household.people.you.not_a_variable`." + ) + assert outcome.error.policy_key is None + assert [error.path for error in outcome.error.input_errors] == [ + "household.people.you.not_a_variable" + ] + + +def test_uk_unrecognized_policy_parameters_name_their_policy() -> None: + request = _request( + "uk", + UK_SINGLE, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": {"gov.not.a.parameter": {}}}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_inputs" + assert outcome.error.policy_key == "reform" + assert [error.path for error in outcome.error.input_errors] == [ + "policy.gov.not.a.parameter" + ] + + +def test_uk_reform_values_given_as_text_are_converted_to_numbers() -> None: + # The Public API converts a reform value to the parameter's type, reading + # numeric text as a number first. + basic_rate = "gov.hmrc.income_tax.rates.uk[0].rate" + request = _request( + "uk", + UK_SINGLE, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + { + "policy_key": "reform", + "parameters": {basic_rate: {"2026-01-01.2100-12-31": "0.3"}}, + }, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + baseline, reform = ( + result.household.to_situation()["households"]["your household"][ + "household_net_income" + ]["2026"] + for result in outcome.output.results + ) + assert isinstance(baseline, float) + assert isinstance(reform, float) + assert reform < baseline + + +def test_uk_deprecated_inputs_warn_in_every_policy_result() -> None: + household = { + **UK_SINGLE, + "people": { + "you": { + **UK_SINGLE["people"]["you"], + "uc_has_limited_capability_for_work": {"2026": True}, + } + }, + } + request = _request( + "uk", + household, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "other-baseline", "parameters": {}}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + for result in outcome.output.results: + assert len(result.warnings) == 1 + assert "uc_has_limited_capability_for_work" in result.warnings[0] + you = result.household.to_situation()["people"]["you"] + assert "uc_has_limited_capability_for_work" not in you + + +def test_uk_spm_settings_are_refused() -> None: + outcome = calculate_household( + _request("uk", UK_SINGLE, spm={}), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.spm_error is not None + assert outcome.error.spm_error.code == "SPM_SETTINGS_UNSUPPORTED" + + +def test_uk_member_who_is_not_a_person_is_an_invalid_household() -> None: + household = { + **UK_SINGLE, + "benunits": {"your benefit unit": {"members": ["you", "ghost"]}}, + } + + outcome = calculate_household( + _request("uk", household), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_household" + assert outcome.error.message.startswith("Invalid household payload: ") + + +def test_country_models_are_loaded_once_per_process() -> None: + first = load_country_household_model("uk") + + assert load_country_household_model("uk") is first + assert first.metadata.entities["benunit"].plural == "benunits" + assert "gov.hmrc.income_tax.rates.uk[0].rate" in first.metadata.parameters From 389c6ccbe0eb24fc9f63315a89e53e4eb3d43690 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:28:23 +0400 Subject: [PATCH 011/124] Add calculate_household_us and calculate_household_uk to the v2 Modal app Each function takes a household request, calculates it with the executor's household calculation and returns the result or typed failure as JSON. It continues the caller's trace through the keyword-only observability context and measures the call as one v2 household operation. The functions run in their own image: the CA store, the locked packages and the source, without the bundle's datasets or the Stage 12 cache, so neither the image build nor the functions use a secret. Each reserves 2 CPUs and 4 GiB, may use up to 16 GiB for large households with axes, times out after 900 seconds, is not retried, and runs at most 20 containers. The other v2 images keep their layers and order. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 27 +++ .../src/modal/v2_app.py | 171 ++++++++++++++++-- .../tests/test_stage12_modal_app.py | 114 ++++++++++++ 3 files changed, 292 insertions(+), 20 deletions(-) diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index 0a2eddcbd..cdbf9fc8f 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -453,6 +453,33 @@ not satisfy the requested output plan is recalculated. Each region-group log records whether it loaded a complete file, recalculated an incomplete file, or computed because no file existed. +## v2 household functions + +The versioned v2 application also defines a household function for each +country, `calculate_household_us` and `calculate_household_uk`. Each call +calculates one household, in the Public API's situation shape and with +optional axes, under one to ten policies, so a baseline and its reform are +calculated in one call. It takes a `HouseholdExecutionRequest` and returns a +`HouseholdExecutionResult` or a typed `HouseholdExecutionFailure` from +`policyengine_simulation_contract.household_execution`, as JSON. The +calculation follows the Public API's household calculation, including its +input errors, warnings and SPM receipt. A function refuses a request for +another country, and returns a `bundle_mismatch` failure for a request that +names another bundle. Nothing calls these functions until the Simulation +Entrypoint has household routes. + +- Image: the CA store, the frozen `modal-simulation-image` dependency group + and the source packages, without the bundle's datasets or the Stage 12 + cache. Building it needs no secret. +- Secrets: none. A household calculation reads no private data, Cloud + Storage or database. Telemetry authenticates with the identity token Modal + gives every container (`MODAL_IDENTITY_TOKEN`) and the workload identity + settings baked into the image at deployment. +- Limits: 2 CPUs, 4,096 MiB of reserved and 16,384 MiB of maximum memory, a + 900-second timeout, no retries, and a Modal `max_containers` value of 20. +- Each container loads a country model the first time it calculates that + country and keeps it for later calls. + ## Temporary direct runner endpoint > **Temporary Stage 12 interface:** The authenticated routes in this section diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 92642d673..396181322 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -1,5 +1,10 @@ # pyright: reportAttributeAccessIssue=false -"""Separately named Stage 12 Modal coordinator and country workers. +"""Separately named v2 Modal application: report coordinator and country workers. + +Besides the Stage 12 report coordinator and simulation workers, it defines a +household function for each country, ``calculate_household_us`` and +``calculate_household_uk``, which calculates one household under one or more +policies in an image without datasets or secrets. This module defines additional resources. It does not rename or modify the existing ``policyengine-simulation-py{version}`` applications. @@ -12,6 +17,9 @@ from pathlib import Path from typing import TYPE_CHECKING +from policyengine_simulation_contract.household_execution import ( + household_callable_name, +) from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_executor import stage12_runtime from policyengine_simulation_executor.hf_access_validation import ( @@ -39,6 +47,7 @@ STAGE12_SEGMENT_STAGES, STAGE12_SHADOW_REPORT_STAGES, STAGE12_SIMULATION_STAGES, + V2_HOUSEHOLD_STAGES, Stage, ) from policyengine_simulation_observability.telemetry import ( @@ -138,6 +147,35 @@ def _country_bundle(country: CountryId) -> Stage12CountryBundle: ) +def _v2_package_image() -> modal.Image: + """Return the base of every v2 image: the CA store and the locked packages.""" + return ( + modal.Image.debian_slim(python_version="3.13") + # Upgrade Debian's CA store before anything is downloaded, and make + # grpcio verify servers against it (src/modal/ca_certificates.py). + .apt_install(OPERATING_SYSTEM_CA_PACKAGE) + .env(GRPC_CA_BUNDLE_ENV) + .uv_sync( + uv_project_dir=_UV_PROJECT_DIR, + frozen=True, + extra_options="--only-group modal-simulation-image", + ) + ) + + +def _add_v2_sources(image: modal.Image) -> modal.Image: + """Copy the source packages and static runtime files every v2 image runs.""" + image = image.add_local_python_source( + "src.modal", + "policyengine_simulation_executor", + "policyengine_simulation_observability", + "policyengine_simulation_contract", + "policyengine_stage12_persistence", + copy=True, + ) + return add_static_runtime_files(image, uv_project_dir=_UV_PROJECT_DIR) + + def build_v2_image( countries: tuple[CountryId, ...], *, include_us_cache: bool = False ) -> modal.Image: @@ -152,16 +190,7 @@ def build_v2_image( for country in countries } image = ( - modal.Image.debian_slim(python_version="3.13") - # Upgrade Debian's CA store before anything is downloaded, and make - # grpcio verify servers against it (src/modal/ca_certificates.py). - .apt_install(OPERATING_SYSTEM_CA_PACKAGE) - .env(GRPC_CA_BUNDLE_ENV) - .uv_sync( - uv_project_dir=_UV_PROJECT_DIR, - frozen=True, - extra_options="--only-group modal-simulation-image", - ) + _v2_package_image() .run_commands( bundle_data_install_command( RESOLVED_BUNDLE.bundle.policyengine_version, @@ -199,20 +228,23 @@ def build_v2_image( # for each precomputed year), downloaded and hashed in turn. timeout=3600, ) - image = image.add_local_python_source( - "src.modal", - "policyengine_simulation_executor", - "policyengine_simulation_observability", - "policyengine_simulation_contract", - "policyengine_stage12_persistence", - copy=True, - ) - return add_static_runtime_files(image, uv_project_dir=_UV_PROJECT_DIR) + return _add_v2_sources(image) + + +def build_household_image() -> modal.Image: + """Build the household functions' image: packages and source only. + + A household calculation reads no dataset, cache, bucket or database, so + the image downloads none and its build needs no secret. The installed + PolicyEngine.py package names the bundle the functions calculate with. + """ + return _add_v2_sources(_v2_package_image().env(modal_image_environment())) us_worker_image = build_v2_image(("us",), include_us_cache=True) uk_worker_image = build_v2_image(("uk",)) coordinator_image = build_v2_image(("us", "uk")) +household_image = build_household_image() def _validate(country: CountryId) -> dict: @@ -481,3 +513,102 @@ def coordinate_report( return result finally: runtime.shutdown() + + +# Household function sizing. A call holds one country model and, for each +# policy, a simulation of the household at every axis point. Each container +# reserves 4 GiB and may use up to 16 GiB before Modal stops it, so a large +# household calculated with every variable at many axis points still fits. +# The timeout is three times the Public API's 300-second request timeout. +HOUSEHOLD_FUNCTION_CPU = 2.0 +HOUSEHOLD_FUNCTION_MEMORY_MIB = (4096, 16384) +HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS = 900 +HOUSEHOLD_FUNCTION_MAX_CONTAINERS = 20 + + +def _run_household_calculation( + country: CountryId, + payload: dict, + observability_context: dict | None, +) -> dict: + """Calculate one household request for ``country`` and return its outcome. + + The outcome is a ``HouseholdExecutionResult`` or ``HouseholdExecutionFailure`` + as JSON. Raises for a malformed request or a request for another country. + """ + from policyengine_simulation_contract.household_execution import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + HouseholdExecutionRequest, + HouseholdWorkerIdentity, + ) + from policyengine_simulation_executor import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + household_calculation, + ) + + runtime = init_process_observability( + service_name=f"policyengine-v2-household-{country}-worker", + service_role="v2_household_worker", + platform="modal", + environment=os.getenv("MODAL_ENVIRONMENT", "local"), + ) + try: + propagated = normalize_observability_context(observability_context) + result = None + with runtime.operation( + V2_HOUSEHOLD_STAGES.name(Stage.V2_HOUSEHOLD_EXECUTION), + attributes={"country": country}, + remote_context=propagated, + ): + outcome = household_calculation.calculate_household( + HouseholdExecutionRequest.model_validate(payload), + worker=HouseholdWorkerIdentity( + application_name=APP_NAME, + function_name=household_callable_name(country), + ), + required_country=country, + dependencies=household_calculation.HouseholdCalculationDependencies( + runtime=runtime + ), + ) + result = outcome.model_dump(mode="json") + if result is None: + msg = f"the {country} household calculation returned no result" + raise RuntimeError(msg) + return result + finally: + runtime.shutdown() + + +@app.function( + image=household_image, + cpu=HOUSEHOLD_FUNCTION_CPU, + memory=HOUSEHOLD_FUNCTION_MEMORY_MIB, + timeout=HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS, + retries=0, + max_containers=HOUSEHOLD_FUNCTION_MAX_CONTAINERS, + secrets=[], +) +def calculate_household_us( + payload: dict, + *, + observability_context: dict | None = None, +) -> dict: + """Calculate one US household under each requested policy.""" + return _run_household_calculation("us", payload, observability_context) + + +@app.function( + image=household_image, + cpu=HOUSEHOLD_FUNCTION_CPU, + memory=HOUSEHOLD_FUNCTION_MEMORY_MIB, + timeout=HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS, + retries=0, + max_containers=HOUSEHOLD_FUNCTION_MAX_CONTAINERS, + secrets=[], +) +def calculate_household_uk( + payload: dict, + *, + observability_context: dict | None = None, +) -> dict: + """Calculate one UK household under each requested policy.""" + return _run_household_calculation("uk", payload, observability_context) diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index 00ae66afc..3c757f0ae 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -247,6 +247,8 @@ def test_v2_app_declares_validation_workers_and_non_http_coordinator( "run_single_simulation_us", "run_single_simulation_uk", "coordinate_report", + "calculate_household_us", + "calculate_household_uk", } assert functions["run_single_simulation_us"]["image"] is module.us_worker_image assert functions["run_single_simulation_uk"]["image"] is module.uk_worker_image @@ -319,6 +321,118 @@ def fail(*_, **__): assert runtime.shutdown_calls == 1 +def test_household_functions_run_without_datasets_or_secrets(monkeypatch) -> None: + module = _load(monkeypatch) + functions = dict(module.app.function_calls) + image_calls = module.household_image.calls + + for country in ("us", "uk"): + declaration = functions[f"calculate_household_{country}"] + assert declaration["image"] is module.household_image + assert declaration["secrets"] == [] + assert declaration["cpu"] == 2.0 + # 4 GiB reserved, up to 16 GiB, for large households with axes. + assert declaration["memory"] == (4096, 16384) + assert declaration["timeout"] == 900 + assert declaration["retries"] == 0 + assert declaration["max_containers"] == 20 + # The image installs the locked packages and the source, but downloads no + # dataset or cache, so building it needs no secret either. + assert [call[0] for call in image_calls[:4]] == [ + "debian_slim", + "apt_install", + "env", + "uv_sync", + ] + assert not any(call[0] in {"run_commands", "run_function"} for call in image_calls) + assert not any( + isinstance(argument, dict) and "secrets" in argument + for call in image_calls + for argument in call[1:] + ) + environment = { + name: value + for call in image_calls + if call[0] == "env" + for name, value in call[1].items() + } + assert "POLICYENGINE_DATA_FOLDER" not in environment + assert "STAGE12_CACHE_MANIFEST_DIGEST" not in environment + assert "OTEL_TRACES_EXPORTER" in environment + local_source = next( + call for call in image_calls if call[0] == "add_local_python_source" + ) + assert "policyengine_simulation_executor" in local_source[1] + + +def _household_payload(country: str) -> dict: + return { + "calculation_id": "00000000-0000-4000-8000-0000000000c1", + "country": country, + "household": {"people": {"you": {"age": {"2026": 40}}}}, + "policies": [{"policy_key": "baseline", "parameters": {}}], + "bundle": { + "policyengine_version": "6.7.5", + "core_package_version": "3.0.0", + "country_package_name": f"policyengine-{country}", + "country_package_version": "1.0.0", + "bundle_manifest_sha256": "a" * 64, + }, + } + + +@pytest.mark.parametrize("country", ["us", "uk"]) +def test_household_functions_calculate_their_own_country_with_telemetry( + monkeypatch, country +) -> None: + from policyengine_simulation_executor import household_calculation + + module = _load(monkeypatch) + runtime = RecordingRuntime() + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + calls: list[dict] = [] + + class Outcome: + def model_dump(self, *, mode): + assert mode == "json" + return {"status": "succeeded"} + + def calculate(request, **keywords): + calls.append({"request": request, **keywords}) + return Outcome() + + monkeypatch.setattr(household_calculation, "calculate_household", calculate) + + result = getattr(module, f"calculate_household_{country}")( + _household_payload(country), + observability_context=_observability_context(), + ) + + assert result == {"status": "succeeded"} + (call,) = calls + assert call["request"].country == country + assert call["required_country"] == country + assert call["worker"].application_name == module.APP_NAME + assert call["worker"].function_name == f"calculate_household_{country}" + assert call["dependencies"].runtime is runtime + (operation,) = runtime.operations + assert operation["name"] == "v2_household_execution" + assert operation["attributes"] == {"country": country} + assert operation["remote_context"]["observability_id"] == OBSERVABILITY_ID + assert runtime.shutdown_calls == 1 + + +def test_household_functions_close_runtime_after_an_error(monkeypatch) -> None: + module = _load(monkeypatch) + runtime = RecordingRuntime() + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + + with pytest.raises(ValueError, match="validation error"): + module.calculate_household_us({"country": "us"}) + + assert runtime.shutdown_calls == 1 + + def test_external_package_values_are_assertions_only(monkeypatch) -> None: install_fake_modal(monkeypatch) monkeypatch.setenv("STAGE12_EXPECT_US_VERSION", "not-the-bundled-version") From 60d778edf0a3b99f0340714d321470cddaaebc2c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:29:45 +0400 Subject: [PATCH 012/124] Validate the household functions at release and record them in the manifest After the country validation functions, release validation calls each deployed household function with a one-adult, current-law household and requires a successful calculation with the deployed bundle, reported by that function, with a number for the household's net income. The release record keeps each household invocation ID, and the manifest publisher records a country's household_callable only when the record shows its function was validated. A household function that fails validation stops the release, as a failed country validation already does. The validation household and its check live in the executor package, so the pre-merge image check can calculate the same household inside the household image. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 10 ++ .../modal/utils/update_v2_version_manifest.py | 14 ++- .../modal/utils/validate_v2_worker_release.py | 95 ++++++++++++-- .../household_validation.py | 93 ++++++++++++++ .../tests/test_household_validation.py | 119 ++++++++++++++++++ .../tests/test_update_v2_version_manifest.py | 13 ++ .../tests/test_validate_v2_worker_release.py | 86 +++++++++++++ 7 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py create mode 100644 projects/policyengine-simulation-executor/tests/test_household_validation.py diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index cdbf9fc8f..23b84522b 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -480,6 +480,16 @@ Entrypoint has household routes. - Each container loads a country model the first time it calculates that country and keeps it for later calls. +Deployment validation calls each household function with a one-adult, +current-law household and requires a successful calculation with the deployed +bundle, reported by that function, with a number for the household's net +income. The validation record keeps each invocation ID, and the manifest +publisher records a country's `household_callable` only when the record shows +its function was validated. A failed household validation fails v2 worker +validation like any other validation failure: the manifest is not published, +and, as described under "Release and deployment constraints", the deployment +does not promote the new Simulation Entrypoint revision. + ## Temporary direct runner endpoint > **Temporary Stage 12 interface:** The authenticated routes in this section diff --git a/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py b/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py index 00dbab4ce..70e6dfd49 100644 --- a/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py +++ b/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py @@ -7,6 +7,9 @@ from pathlib import Path import modal +from policyengine_simulation_contract.household_execution import ( + household_callable_name, +) from policyengine_simulation_contract.stage12_manifest import ( V1_ROUTING_STATE_NAME, V2_VERSION_MANIFEST_NAME, @@ -25,8 +28,10 @@ def build_worker(validation_payload: object) -> V2WorkerVersion: """Build the installed bundle's v2 worker entry from a validation record. Every bundle country has a single-simulation worker, so each is recorded - as economy-capable. Raises ValueError if the record is invalid or the - entry carries a credential-like key. + as economy-capable. A country's household function is recorded when the + record shows that it calculated the validation household. Raises + ValueError if the record is invalid or the entry carries a credential-like + key. """ resolved = load_stage12_bundle() validation = V2WorkerValidation.model_validate(validation_payload) @@ -38,6 +43,11 @@ def build_worker(validation_payload: object) -> V2WorkerVersion: country=country.country, economy_capable=True, single_simulation_callable=f"run_single_simulation_{country.country}", + household_callable=( + household_callable_name(country.country) + if country.country in validation.household_validation_invocation_ids + else None + ), ) for country in resolved.bundle.countries ), diff --git a/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py b/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py index 198b019ef..d5fee48f6 100644 --- a/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py +++ b/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py @@ -1,4 +1,10 @@ -"""Invoke and verify every deployed Stage 12 country validation function.""" +"""Invoke and verify every deployed v2 validation and household function. + +Each country's validation function checks its simulation worker image, and +each country's household function calculates a one-adult, current-law +household. The release record names every invocation, so the manifest names +only functions that succeeded in the deployed application. +""" from __future__ import annotations @@ -10,30 +16,40 @@ from uuid import uuid4 import modal +from policyengine_simulation_contract.household_execution import ( + HouseholdWorkerIdentity, + household_callable_name, +) from policyengine_simulation_contract.stage12_manifest import ( ManifestText, Stage12CacheRelease, V2WorkerValidation, v2_application_name, ) +from policyengine_simulation_executor.household_validation import ( + check_household_validation, + household_validation_request, +) from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle if TYPE_CHECKING: from policyengine_simulation_contract.stage12_bundle import CountryId -def _spawn_validation( +def _invoke_deployed_function( *, application_name: str, function_name: str, environment: str, + arguments: tuple[object, ...] = (), ) -> tuple[Any, str]: + """Call a deployed function, wait for its result, and return it with the call ID.""" function = modal.Function.from_name( application_name, function_name, environment_name=environment, ) - call = function.spawn() + call = function.spawn(*arguments) result = call.get(timeout=1_000) invocation_id = getattr(call, "object_id", None) if not isinstance(invocation_id, str) or not invocation_id: @@ -42,14 +58,73 @@ def _spawn_validation( return result, invocation_id +def _spawn_validation( + *, + application_name: str, + function_name: str, + environment: str, +) -> tuple[Any, str]: + return _invoke_deployed_function( + application_name=application_name, + function_name=function_name, + environment=environment, + ) + + +def _call_household_function( + *, + application_name: str, + function_name: str, + environment: str, + payload: dict[str, Any], +) -> tuple[Any, str]: + return _invoke_deployed_function( + application_name=application_name, + function_name=function_name, + environment=environment, + arguments=(payload,), + ) + + +def validate_household_functions( + *, application_name: str, environment: str, year: int +) -> dict[CountryId, ManifestText]: + """Calculate a validation household with each country's household function. + + Returns each country's invocation ID. Raises RuntimeError when a function + does not calculate the household as ``check_household_validation`` requires. + """ + invocation_ids: dict[CountryId, ManifestText] = {} + for country_bundle in load_stage12_bundle().bundle.countries: + country = country_bundle.country + function_name = household_callable_name(country) + request = household_validation_request(country, year=year) + outcome, invocation_id = _call_household_function( + application_name=application_name, + function_name=function_name, + environment=environment, + payload=request.model_dump(mode="json"), + ) + check_household_validation( + outcome, + request=request, + worker=HouseholdWorkerIdentity( + application_name=application_name, function_name=function_name + ), + ) + invocation_ids[country] = invocation_id + return invocation_ids + + def validate_release( *, environment: str, expected_cache_manifest_sha256: str ) -> V2WorkerValidation: - """Invoke each deployed country validation function and return the release record. + """Invoke each deployed validation and household function; return the record. - Raises RuntimeError when a function does not report success, returns no - invocation id, reports bundle values that differ from the local bundle, or - reports a US cache other than ``expected_cache_manifest_sha256``. + Raises RuntimeError when a validation function does not report success, + returns no invocation id, reports bundle values that differ from the local + bundle, or reports a US cache other than ``expected_cache_manifest_sha256``, + or when a household function does not calculate its validation household. """ resolved = load_stage12_bundle() application_name = v2_application_name(resolved.bundle.policyengine_version) @@ -93,6 +168,11 @@ def validate_release( msg = f"{function_name} unexpectedly reported a cache" raise RuntimeError(msg) invocation_ids[country_bundle.country] = invocation_id + household_invocation_ids = validate_household_functions( + application_name=application_name, + environment=environment, + year=datetime.now(UTC).year, + ) return V2WorkerValidation( validated=True, application_name=application_name, @@ -100,6 +180,7 @@ def validate_release( validated_at=datetime.now(UTC), validation_invocation_id=f"stage12-validation-{uuid4()}", country_validation_invocation_ids=invocation_ids, + household_validation_invocation_ids=household_invocation_ids, cache=cache_release, ) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py new file mode 100644 index 000000000..76d842744 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py @@ -0,0 +1,93 @@ +"""The household that checks a v2 household function calculates. + +Release validation calls each deployed household function with this +household, and the pre-merge image check calculates it inside the household +image. Both use ``check_household_validation`` to decide whether it worked. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, + HouseholdExecutionResult, + parse_household_execution_outcome, +) + +from policyengine_simulation_executor.household_calculation import ( + installed_household_bundle, +) + +if TYPE_CHECKING: + from policyengine_simulation_contract.household_execution import ( + HouseholdWorkerIdentity, + ) + from policyengine_simulation_contract.stage12_bundle import CountryId + +# The group entity, besides the household, that holds the adult in each +# country's validation household. +_ADULT_GROUP_PLURALS: dict[str, str] = {"us": "spm_units", "uk": "benunits"} + + +def household_validation_request( + country: CountryId, *, year: int +) -> HouseholdExecutionRequest: + """Return a one-adult, current-law household request for ``country``. + + The request names the installed bundle and asks for the household's net + income in ``year``. + """ + period = str(year) + household = { + "people": {"you": {"age": {period: 40}, "employment_income": {period: 30_000}}}, + _ADULT_GROUP_PLURALS[country]: {"your unit": {"members": ["you"]}}, + "households": { + "your household": { + "members": ["you"], + "household_net_income": {period: None}, + } + }, + } + return HouseholdExecutionRequest.model_validate( + { + "calculation_id": uuid4(), + "country": country, + "household": household, + "policies": [{"policy_key": "baseline", "parameters": {}}], + "bundle": installed_household_bundle(country), + } + ) + + +def check_household_validation( + outcome: object, + *, + request: HouseholdExecutionRequest, + worker: HouseholdWorkerIdentity, +) -> float: + """Return the household net income ``outcome`` calculated for ``request``. + + Raises RuntimeError unless the outcome is a success with the request's + bundle, reported by ``worker``, with a number for the household's net + income. + """ + name = worker.function_name + parsed = parse_household_execution_outcome(outcome) + if not isinstance(parsed, HouseholdExecutionResult): + msg = f"{name} did not calculate the validation household" + raise RuntimeError(msg) # noqa: TRY004 # a failed calculation fails the check; it is not a type error + if parsed.output.bundle != request.bundle: + msg = f"{name} calculated with another bundle" + raise RuntimeError(msg) + if parsed.worker != worker: + msg = f"{name} reported another function" + raise RuntimeError(msg) + situation = parsed.output.results[0].household.to_situation() + net_income = situation["households"]["your household"]["household_net_income"] + values = list(net_income.values()) if isinstance(net_income, dict) else [] + if len(values) != 1 or not isinstance(values[0], float): + msg = f"{name} returned no household net income" + raise RuntimeError(msg) + return values[0] diff --git a/projects/policyengine-simulation-executor/tests/test_household_validation.py b/projects/policyengine-simulation-executor/tests/test_household_validation.py new file mode 100644 index 000000000..a82abb2df --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_validation.py @@ -0,0 +1,119 @@ +"""Tests for the household that checks a v2 household function calculates.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, + HouseholdWorkerIdentity, +) +from policyengine_simulation_executor.household_calculation import ( + installed_household_bundle, +) +from policyengine_simulation_executor.household_validation import ( + check_household_validation, + household_validation_request, +) + +WORKER = HouseholdWorkerIdentity( + application_name="policyengine-simulation-v2-py-test", + function_name="calculate_household_uk", +) + + +def _succeeded(request: HouseholdExecutionRequest) -> dict: + situation = request.household.to_situation() + situation["households"]["your household"]["household_net_income"] = { + "2026": 25_000.0 + } + return { + "status": "succeeded", + "schema_version": 1, + "calculation_id": str(request.calculation_id), + "output": { + "country": request.country, + "bundle": request.bundle.model_dump(mode="json"), + "results": [{"policy_key": "baseline", "household": situation}], + }, + "worker": WORKER.model_dump(mode="json"), + } + + +@pytest.mark.parametrize( + ("country", "group_plural"), [("us", "spm_units"), ("uk", "benunits")] +) +def test_the_validation_household_is_one_adult_under_current_law( + country, group_plural +) -> None: + request = household_validation_request(country, year=2026) + + situation = request.household.to_situation() + assert request.country == country + assert request.bundle == installed_household_bundle(country) + assert [policy.parameters for policy in request.policies] == [{}] + assert list(situation["people"]) == ["you"] + assert situation[group_plural] == {"your unit": {"members": ["you"]}} + assert situation["households"]["your household"]["household_net_income"] == { + "2026": None + } + + +def test_a_calculated_net_income_passes_the_check() -> None: + request = household_validation_request("uk", year=2026) + + net_income = check_household_validation( + _succeeded(request), request=request, worker=WORKER + ) + + assert net_income == 25_000.0 + + +def _failed(outcome: dict) -> dict: + return { + "status": "failed", + "schema_version": 1, + "calculation_id": outcome["calculation_id"], + "error": {"code": "calculation_failed", "message": "model exploded"}, + "worker": outcome["worker"], + } + + +def _with_another_bundle(outcome: dict) -> dict: + changed = deepcopy(outcome) + changed["output"]["bundle"]["policyengine_version"] = "0.0.1" + return changed + + +def _from_another_function(outcome: dict) -> dict: + changed = deepcopy(outcome) + changed["worker"]["function_name"] = "calculate_household_us" + return changed + + +def _without_net_income(outcome: dict) -> dict: + changed = deepcopy(outcome) + household = changed["output"]["results"][0]["household"] + household["households"]["your household"]["household_net_income"] = {"2026": None} + return changed + + +@pytest.mark.parametrize( + ("change_outcome", "message"), + [ + (_failed, "did not calculate the validation household"), + (_with_another_bundle, "calculated with another bundle"), + (_from_another_function, "reported another function"), + (_without_net_income, "returned no household net income"), + ], +) +def test_an_outcome_that_is_not_the_calculated_household_fails_the_check( + change_outcome, message +) -> None: + request = household_validation_request("uk", year=2026) + + with pytest.raises(RuntimeError, match=f"calculate_household_uk {message}"): + check_household_validation( + change_outcome(_succeeded(request)), request=request, worker=WORKER + ) diff --git a/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py b/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py index cee40de8d..f37e3697b 100644 --- a/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py +++ b/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py @@ -55,6 +55,19 @@ def test_publisher_records_every_bundle_country_as_economy_capable() -> None: ] +def test_publisher_records_each_validated_household_function() -> None: + payload = _validation().model_dump(mode="json") + payload["household_validation_invocation_ids"] = {"uk": "household-uk-1"} + + worker = publisher.build_worker(payload) + + assert { + country.country: country.household_callable for country in worker.countries + } == {"us": None, "uk": "calculate_household_uk"} + assert worker.household_worker("uk") is not None + assert worker.household_worker("us") is None + + def test_publisher_rejects_validation_for_another_application() -> None: payload = _validation().model_dump(mode="json") payload["application_name"] = "policyengine-simulation-v2-py-another" diff --git a/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py b/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py index c5b555c9d..09e156f0a 100644 --- a/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py +++ b/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py @@ -3,9 +3,54 @@ from __future__ import annotations import pytest +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, +) from src.modal.utils import validate_v2_worker_release as validation +def _household_outcome( + request: HouseholdExecutionRequest, *, application_name: str, function_name: str +) -> dict: + """Return the success a household function reports for ``request``.""" + situation = request.household.to_situation() + for period in situation["households"]["your household"]["household_net_income"]: + situation["households"]["your household"]["household_net_income"][period] = ( + 25_000.0 + ) + return { + "status": "succeeded", + "schema_version": 1, + "calculation_id": str(request.calculation_id), + "output": { + "country": request.country, + "bundle": request.bundle.model_dump(mode="json"), + "results": [{"policy_key": "baseline", "household": situation}], + }, + "worker": { + "application_name": application_name, + "function_name": function_name, + }, + } + + +class HouseholdFunctions: + """Stands in for the deployed household functions; records each call.""" + + def __init__(self, change_outcome=None) -> None: + self.calls: list[tuple[str, HouseholdExecutionRequest]] = [] + self.change_outcome = change_outcome or (lambda outcome: outcome) + + def __call__(self, *, application_name, function_name, environment, payload): + assert environment == "staging" + request = HouseholdExecutionRequest.model_validate(payload) + self.calls.append((function_name, request)) + outcome = _household_outcome( + request, application_name=application_name, function_name=function_name + ) + return self.change_outcome(outcome), f"household-{request.country}" + + def test_all_country_results_must_match_the_deployment_bundle(monkeypatch) -> None: invocation_ids: list[str] = [] @@ -45,6 +90,8 @@ def fake_spawn_validation(*, application_name, function_name, environment): ) monkeypatch.setattr(validation, "_spawn_validation", fake_spawn_validation) + household_functions = HouseholdFunctions() + monkeypatch.setattr(validation, "_call_household_function", household_functions) result = validation.validate_release( environment="staging", @@ -59,6 +106,16 @@ def fake_spawn_validation(*, application_name, function_name, environment): assert invocation_ids == ["call-us", "call-uk"] assert result.cache is not None assert result.cache.manifest_sha256 == "c" * 64 + # Each household function calculated a current-law household with the + # deployment's bundle, and the record names each invocation. + assert [name for name, _ in household_functions.calls] == [ + "calculate_household_us", + "calculate_household_uk", + ] + assert result.household_validation_invocation_ids == { + "us": "household-us", + "uk": "household-uk", + } def test_country_result_mismatch_stops_publication(monkeypatch) -> None: @@ -80,3 +137,32 @@ def test_country_result_mismatch_stops_publication(monkeypatch) -> None: environment="staging", expected_cache_manifest_sha256="c" * 64, ) + + +def _fail(outcome: dict) -> dict: + return { + "status": "failed", + "schema_version": 1, + "calculation_id": outcome["calculation_id"], + "error": {"code": "calculation_failed", "message": "model exploded"}, + "worker": outcome["worker"], + } + + +def test_a_household_function_that_does_not_calculate_stops_publication( + monkeypatch, +) -> None: + household_functions = HouseholdFunctions(_fail) + monkeypatch.setattr(validation, "_call_household_function", household_functions) + + with pytest.raises( + RuntimeError, + match="calculate_household_us did not calculate the validation household", + ): + validation.validate_household_functions( + application_name="policyengine-simulation-v2-py-test", + environment="staging", + year=2026, + ) + + assert [name for name, _ in household_functions.calls] == ["calculate_household_us"] From 00b3409d294691a6ca268ad221570f3a548e2e22 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:33:36 +0400 Subject: [PATCH 013/124] Build every v2 image in the pre-merge image check The image check now also runs a v2 image smoke. It builds the report coordinator, UK worker and household images the v2 application deploys, and the US worker image without its last layer, the Stage 12 cache, which only a deployment can fetch: the deployment's precompute writes the cache, and the image build reads it with the deployment's Cloud Storage credential. That layer adds data files, not Python packages. In each report image the smoke imports the v2 application module, as Modal does before it runs a function, and the modules the coordinator, workers and validation functions import while handling a request. In the household image it imports the household entry points and calculates the release validation household for each country, which shows the image calculates without datasets. The smoke functions use no secret. Modal builds the smoke's images together. The job's timeout rises from 45 to 90 minutes, because after a relock both the executor smoke and the v2 smoke download certified datasets. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/modal-image-smoke.sh | 12 +- .github/workflows/pr-image-smoke.yml | 7 +- .../README.md | 8 +- .../src/modal/v2_image_smoke.py | 152 ++++++++++++++++++ .../integration/test_image_smoke_modal.py | 7 + .../tests/test_modal_scripts.py | 10 +- .../tests/test_v2_image_smoke.py | 129 +++++++++++++++ 7 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 projects/policyengine-simulation-executor/src/modal/v2_image_smoke.py create mode 100644 projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py diff --git a/.github/scripts/modal-image-smoke.sh b/.github/scripts/modal-image-smoke.sh index dc9c0062f..b8aede54e 100755 --- a/.github/scripts/modal-image-smoke.sh +++ b/.github/scripts/modal-image-smoke.sh @@ -3,8 +3,10 @@ # Usage: ./modal-image-smoke.sh # # Each smoke imports the true app entrypoints inside the real (or, for -# the executor, prefix-identical) Modal image — catching in-image -# dependency breakage (issue #602's class) before merge. +# the executor and the v2 US worker, prefix-identical) Modal image — +# catching in-image dependency breakage (issue #602's class) before merge. +# The v2 image smoke builds every image of the v2 application: the report +# coordinator, the country simulation workers and the household functions. set -euo pipefail @@ -47,4 +49,10 @@ echo "=== Stage 12 static runtime file smoke (env: $MODAL_ENV) ===" src/modal/v2_static_runtime_files_smoke.py ) +echo "=== v2 image smoke: every v2 image and its entry points (env: $MODAL_ENV) ===" +( + cd "$REPO_ROOT/projects/policyengine-simulation-executor" + uv run --frozen modal run --env="$MODAL_ENV" src/modal/v2_image_smoke.py +) + echo "=== Image smokes passed ===" diff --git a/.github/workflows/pr-image-smoke.yml b/.github/workflows/pr-image-smoke.yml index 730b517d5..fd00793b4 100644 --- a/.github/workflows/pr-image-smoke.yml +++ b/.github/workflows/pr-image-smoke.yml @@ -33,9 +33,10 @@ jobs: # Same-repository pull requests only, and in a stack only the lowest # unmerged one (see docs/engineering/skills/github-prs.md). if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (!github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref) }} - # Warm cache: ~2 min. After a relock the executor smoke pays the - # certified dataset download layer (~15-20 min). - timeout-minutes: 45 + # Warm cache: a few minutes. After a relock the executor smoke and the + # v2 image smoke each pay their certified dataset download layers + # (~15-20 min each; Modal builds the v2 smoke's images together). + timeout-minutes: 90 steps: - uses: actions/checkout@v6 diff --git a/projects/policyengine-simulation-executor/README.md b/projects/policyengine-simulation-executor/README.md index 37f5e91cb..9f6a47558 100644 --- a/projects/policyengine-simulation-executor/README.md +++ b/projects/policyengine-simulation-executor/README.md @@ -28,8 +28,12 @@ in the project dependencies and `modal-simulation-image`, then run `uv lock`. The automated updater changes only those wrapper requirements; it reads the wrapper's release manifest to verify and report the selected component and data versions. Other image dependencies belong in `modal-simulation-image`. PRs -touching image inputs run an in-image import smoke (`src/modal/smoke_app.py` via -`.github/workflows/pr-image-smoke.yml`). Note that any change to the +touching image inputs run in-image import smokes through +`.github/workflows/pr-image-smoke.yml`: `src/modal/smoke_app.py` for the v1 +worker image, and `src/modal/v2_image_smoke.py` for every v2 image (the report +coordinator, the country simulation workers, the US one without its Stage 12 +cache layer, and the household functions, where it also calculates a +household). Note that any change to the group or lock invalidates the image layer cache, including the artifact fetch layer below. diff --git a/projects/policyengine-simulation-executor/src/modal/v2_image_smoke.py b/projects/policyengine-simulation-executor/src/modal/v2_image_smoke.py new file mode 100644 index 000000000..89ef78504 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/modal/v2_image_smoke.py @@ -0,0 +1,152 @@ +# pyright: reportAttributeAccessIssue=false, reportFunctionMemberAccess=false +"""Pre-merge image smoke: build every v2 Modal image and import its entry points. + +The coordinator, UK worker and household images are the ones the v2 +application deploys. The US worker image is built without its last layer, the +Stage 12 cache: only a deployment can add that layer, because the deployment's +precompute writes the cache and the image build reads it with the deployment's +Cloud Storage credential. The layer adds data files, not Python packages. + +In each image the smoke imports the v2 application module, as Modal does +before it runs any of the application's functions, and the modules those +functions import while handling a request. In the household image it also +calculates the release validation household for each country, which shows the +image calculates households without datasets. + +Usage: + uv run modal run --env=staging src/modal/v2_image_smoke.py +""" + +from src.modal.v2_app import ( + build_v2_image, + coordinator_image, + household_image, + uk_worker_image, +) + +import modal + +SMOKE_APP_NAME = "policyengine-simulation-v2-image-smoke" +app = modal.App(SMOKE_APP_NAME) + +us_worker_image_without_cache = build_v2_image(("us",)) + +# Modules the report coordinator, simulation workers and validation functions +# import when they start or while they handle a request. +REPORT_ENTRY_POINT_MODULES = ( + "src.modal.v2_app", + "policyengine_simulation_executor.stage12_runtime", + "policyengine_simulation_executor.stage12_worker_validation", + "policyengine_simulation_executor.simulation_runtime", + "policyengine_simulation_executor.spm", + "policyengine_simulation_executor.uk_local_authority_metadata", + "microdf", + "policyengine.tax_benefit_models.us.datasets", + "policyengine.tax_benefit_models.uk.datasets", +) +# Modules the household functions import when they start or calculate. +HOUSEHOLD_ENTRY_POINT_MODULES = ( + "src.modal.v2_app", + "policyengine_simulation_executor.household_calculation", + "policyengine_simulation_executor.household_validation", +) + + +def _import_modules(names: tuple[str, ...]) -> list[str]: + """Import each named module, raising on the first that fails.""" + import importlib # noqa: PLC0415 # smoke app: standard-library import kept with the in-image check that uses it + + for name in names: + importlib.import_module(name) + return list(names) + + +def _report_image_smoke() -> dict[str, object]: + """Import the report entry points; return them and the installed bundle version.""" + from policyengine_simulation_executor.stage12_bundle import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + load_stage12_bundle, + ) + + return { + "modules": _import_modules(REPORT_ENTRY_POINT_MODULES), + "policyengine_version": load_stage12_bundle().bundle.policyengine_version, + } + + +@app.function(image=coordinator_image, timeout=600, memory=8192) +def smoke_v2_coordinator_image() -> dict[str, object]: + """Import the v2 entry points inside the report coordinator's image.""" + return _report_image_smoke() + + +@app.function(image=uk_worker_image, timeout=600, memory=8192) +def smoke_v2_uk_worker_image() -> dict[str, object]: + """Import the v2 entry points inside the UK simulation worker's image.""" + return _report_image_smoke() + + +@app.function(image=us_worker_image_without_cache, timeout=600, memory=8192) +def smoke_v2_us_worker_image() -> dict[str, object]: + """Import the v2 entry points inside the US simulation worker's image. + + The image has every layer of the deployed image except the Stage 12 cache. + """ + return _report_image_smoke() + + +@app.function(image=household_image, timeout=900, memory=(4096, 16384)) +def smoke_v2_household_image() -> dict[str, object]: + """Import the household entry points and calculate each validation household. + + Raises when a calculation fails or does not return a household net income. + """ + from datetime import ( # noqa: PLC0415 # smoke app: standard-library import kept with the in-image check that uses it + UTC, + datetime, + ) + + from policyengine_simulation_contract.household_execution import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + HouseholdWorkerIdentity, + household_callable_name, + ) + from policyengine_simulation_executor import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + household_calculation, + ) + from policyengine_simulation_executor.household_validation import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + check_household_validation, + household_validation_request, + ) + from policyengine_simulation_executor.stage12_bundle import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + load_stage12_bundle, + ) + + modules = _import_modules(HOUSEHOLD_ENTRY_POINT_MODULES) + year = datetime.now(UTC).year + net_incomes: dict[str, float] = {} + for country_bundle in load_stage12_bundle().bundle.countries: + country = country_bundle.country + request = household_validation_request(country, year=year) + worker = HouseholdWorkerIdentity( + application_name=SMOKE_APP_NAME, + function_name=household_callable_name(country), + ) + outcome = household_calculation.calculate_household( + request, worker=worker, required_country=country + ) + net_incomes[country] = check_household_validation( + outcome.model_dump(mode="json"), request=request, worker=worker + ) + return {"modules": modules, "household_net_income": net_incomes} + + +@app.local_entrypoint() +def main() -> None: + """Run every v2 image smoke remotely and print their reports.""" + reports = { + "coordinator": smoke_v2_coordinator_image.remote(), + "uk_worker": smoke_v2_uk_worker_image.remote(), + "us_worker": smoke_v2_us_worker_image.remote(), + "household": smoke_v2_household_image.remote(), + } + print(reports) # noqa: T201 # Modal local entrypoint: runs locally, and stdout is the smoke's report + print("v2 image smoke OK") # noqa: T201 # Modal local entrypoint: runs locally, and stdout is the smoke's report diff --git a/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py b/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py index 20e240bc1..560e5334e 100644 --- a/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py +++ b/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py @@ -38,3 +38,10 @@ def test_stage12_static_runtime_file_image_smoke(): result = _run_modal_smoke("src/modal/v2_static_runtime_files_smoke.py") assert result.returncode == 0, result.stdout + result.stderr assert "Stage 12 static runtime file smoke OK" in result.stdout + + +@pytest.mark.integration +def test_v2_image_smoke(): + result = _run_modal_smoke("src/modal/v2_image_smoke.py") + assert result.returncode == 0, result.stdout + result.stderr + assert "v2 image smoke OK" in result.stdout diff --git a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py index d427800d9..b29699da8 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py @@ -182,7 +182,15 @@ def test_syncs_expected_hf_key_before_image_smokes(self, tmp_path): "run --frozen modal secret create pe-uk-private-hf-read-token " f"HUGGING_FACE_TOKEN={FAKE_HF_TOKEN} --env=staging --force" ) - assert len(calls) == 4 + assert calls[1:] == [ + "run --frozen modal run --env=staging " + "src/policyengine_simulation_gateway/smoke_app.py", + "run --frozen modal run --env=staging src/modal/smoke_app.py", + "run --frozen modal run --env=staging " + "src/modal/v2_static_runtime_files_smoke.py", + # Builds every v2 image and imports its entry points. + "run --frozen modal run --env=staging src/modal/v2_image_smoke.py", + ] def test_workflow_supplies_hf_token(self): """CI must validate the GitHub value before synchronizing it to Modal.""" diff --git a/projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py b/projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py new file mode 100644 index 000000000..56341d053 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py @@ -0,0 +1,129 @@ +"""Tests for the pre-merge smoke of every v2 Modal image.""" + +from __future__ import annotations + +import importlib +import sys + +import pytest +from fixtures.fake_modal import install_fake_modal +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionResult, +) + + +@pytest.fixture +def v2_modules(monkeypatch): + """Import the v2 application and its image smoke under the fake Modal.""" + install_fake_modal(monkeypatch) + sys.modules.pop("src.modal.v2_app", None) + sys.modules.pop("src.modal.v2_image_smoke", None) + v2_app = importlib.import_module("src.modal.v2_app") + return v2_app, importlib.import_module("src.modal.v2_image_smoke") + + +def test_the_smoke_builds_every_v2_image(v2_modules) -> None: + v2_app, smoke = v2_modules + functions = dict(smoke.app.function_calls) + + assert functions["smoke_v2_coordinator_image"]["image"] is v2_app.coordinator_image + assert functions["smoke_v2_uk_worker_image"]["image"] is v2_app.uk_worker_image + assert functions["smoke_v2_household_image"]["image"] is v2_app.household_image + # The US worker image without its last data layer, the Stage 12 cache, + # which only a deployment can fetch: the deployment's precompute writes it + # with the deployment's Cloud Storage credential. + us_image = functions["smoke_v2_us_worker_image"]["image"] + data_install = next(call for call in us_image.calls if call[0] == "run_commands") + assert "--country us" in data_install[1][0] + assert "--country uk" not in data_install[1][0] + assert not any(call[0] == "run_function" for call in us_image.calls) + assert [call[0] for call in us_image.calls] == [ + call[0] for call in v2_app.us_worker_image.calls if call[0] != "run_function" + ] + assert all( + declaration.get("secrets") in (None, []) for declaration in functions.values() + ) + + +@pytest.mark.parametrize( + "function_name", + [ + "smoke_v2_coordinator_image", + "smoke_v2_uk_worker_image", + "smoke_v2_us_worker_image", + ], +) +def test_report_image_smokes_import_the_v2_entry_points( + v2_modules, function_name +) -> None: + _, smoke = v2_modules + + report = getattr(smoke, function_name)() + + assert report["modules"] == list(smoke.REPORT_ENTRY_POINT_MODULES) + assert { + "src.modal.v2_app", + "policyengine_simulation_executor.stage12_runtime", + "policyengine_simulation_executor.stage12_worker_validation", + # Imported only while handling a request. + "policyengine_simulation_executor.simulation_runtime", + "microdf", + } <= set(report["modules"]) + assert report["policyengine_version"] + + +def test_household_image_smoke_calculates_each_validation_household( + monkeypatch, v2_modules +) -> None: + from policyengine_simulation_executor import household_calculation + + _, smoke = v2_modules + calculated: list[str] = [] + + def calculate(request, *, worker, required_country): + assert required_country == request.country + calculated.append(request.country) + situation = request.household.to_situation() + net_income = situation["households"]["your household"]["household_net_income"] + for period in net_income: + net_income[period] = 20_000.0 + return HouseholdExecutionResult.model_validate( + { + "calculation_id": request.calculation_id, + "output": { + "country": request.country, + "bundle": request.bundle, + "results": [{"policy_key": "baseline", "household": situation}], + }, + "worker": worker, + } + ) + + monkeypatch.setattr(household_calculation, "calculate_household", calculate) + + report = smoke.smoke_v2_household_image() + + assert calculated == ["us", "uk"] + assert report["household_net_income"] == {"us": 20_000.0, "uk": 20_000.0} + assert report["modules"] == [ + "src.modal.v2_app", + "policyengine_simulation_executor.household_calculation", + "policyengine_simulation_executor.household_validation", + ] + + +def test_the_household_image_smoke_fails_when_a_calculation_fails( + monkeypatch, v2_modules +) -> None: + from policyengine_simulation_executor import household_calculation + + _, smoke = v2_modules + + def fail(request, *, worker, required_country): + msg = "model exploded" + raise RuntimeError(msg) + + monkeypatch.setattr(household_calculation, "calculate_household", fail) + + with pytest.raises(RuntimeError, match="model exploded"): + smoke.smoke_v2_household_image() From 9063f1045b1755e905b80b5f8cde75672d518356 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:01:37 +0400 Subject: [PATCH 014/124] Word uncertifiable SPM receipts exactly as the Public API does A household's SPM receipt that the installed country model reports in a form that cannot be certified now fails with the Public API's reason, checked in its order: the forecast hash, scenario and geography kind are all required before they are compared with the selection, then the years mapping, the runtime versions and the forecast ID. The contract's receipt model words its year and metro-area errors as the Public API's model does, and a receipt that is not an object, or whose years are not a mapping, is reported the same way. Co-Authored-By: Claude Opus 5.5 --- .../policyengine_simulation_contract/spm.py | 8 +-- .../household_spm.py | 52 +++++++++----- .../tests/test_household_spm.py | 70 +++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py index c2bddcae1..e666da24f 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py @@ -193,10 +193,10 @@ def serialize_provenance( # noqa: ANN201 # a return annotation would become th def validate_years(cls, value: tuple[str, ...]) -> tuple[str, ...]: """Require four-digit years, sorted ascending with no duplicates.""" if any(re.fullmatch(r"[0-9]{4}", year) is None for year in value): - msg = "SPM provenance years must use four-digit years" + msg = "years must contain four-digit calendar years" raise ValueError(msg) if value != tuple(sorted(set(value))): - msg = "SPM provenance years must be sorted and unique" + msg = "years must be sorted and unique" raise ValueError(msg) return value @@ -205,10 +205,10 @@ def validate_provenance_location(self) -> "SPMProvenance": """Require ``geography_id`` for metro receipts and forbid it otherwise.""" if self.geography_kind == "metro": if self.geography_id is None or not self.geography_id.strip(): - msg = "An SPM area receipt requires geography_id" + msg = "A metro SPM receipt requires geography_id" raise ValueError(msg) elif self.geography_id is not None: - msg = "Only an SPM area receipt accepts geography_id" + msg = "Only a metro SPM receipt accepts geography_id" raise ValueError(msg) return self diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py index 2c5b61d29..56860cee3 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py @@ -323,51 +323,69 @@ def _receipt_mapping(receipt: Mapping[str, object], field_name: str) -> Mapping: value = receipt.get(field_name) if not isinstance(value, Mapping): msg = f"Country SPM receipt requires {field_name}" - raise TypeError(msg) + raise ValueError(msg) # noqa: TRY004 # only ValueError means an uncertifiable receipt, as in the Public API return value +def _receipt_years(receipt: Mapping[str, object]) -> tuple[str, ...]: + """Return the receipt's years, sorted; raise ValueError if they are unusable.""" + source_years = receipt.get("years") + if not isinstance(source_years, Mapping): + msg = "Country SPM receipt requires a years mapping" + raise ValueError(msg) # noqa: TRY004 # only ValueError means an uncertifiable receipt, as in the Public API + if any(not isinstance(year, str) for year in source_years): + msg = "Country SPM receipt years must be strings" + raise ValueError(msg) + return tuple(sorted(source_years)) + + def _compact_receipt( selection: SPMSelection, receipt: Mapping[str, object] ) -> SPMProvenance: - """Reduce the country model's rich receipt to the compact public receipt.""" + """Reduce the country model's rich receipt to the compact public receipt. + + Checks run in the Public API's order, so the first problem reported is the + one it reports: the required texts, their match with the selection, the + years, the runtime versions, then the forecast ID. + """ forecast_sha256 = _required_text(receipt, "forecast_sha256") + scenario = _required_text(receipt, "scenario") + geography_kind = _required_text(receipt, "geography_kind") if ( forecast_sha256 != selection.forecast_content_sha256 - or _required_text(receipt, "scenario") != selection.scenario - or _required_text(receipt, "geography_kind") != selection.geography_kind + or scenario != selection.scenario + or geography_kind != selection.geography_kind ): msg = "Country SPM receipt differs from the SPM configuration" raise ValueError(msg) - source_years = _receipt_mapping(receipt, "years") - if any(not isinstance(year, str) for year in source_years): - msg = "Country SPM receipt years must be strings" - raise ValueError(msg) + years = _receipt_years(receipt) + runtime_versions = SPMRuntimeVersions.model_validate( + _receipt_mapping(receipt, "runtime_versions") + ) return build_spm_provenance( forecast_id=_required_text(receipt, "forecast_id"), forecast_sha256=forecast_sha256, selection=selection, - years=tuple(sorted(source_years)), - runtime_versions=SPMRuntimeVersions.model_validate( - _receipt_mapping(receipt, "runtime_versions") - ), + years=years, + runtime_versions=runtime_versions, ) def _country_receipt(simulation: object) -> SPMProvenance: """Reduce the simulation's receipt. - Raises ValueError or TypeError if the receipt cannot be certified. + Raises ValueError if the receipt cannot be certified. A simulation with + ``spm_config`` but no ``spm_provenance`` method raises AttributeError, as + in the Public API. """ selection = _complete_settings(getattr(simulation, "spm_config", None)) if selection is None: msg = "Country SPM configuration is incomplete" raise ValueError(msg) - receipt_method = getattr(simulation, "spm_provenance", None) - source = receipt_method() if callable(receipt_method) else None + source = simulation.spm_provenance() # pyright: ignore[reportAttributeAccessIssue] # a country Simulation with spm_config has this method if not isinstance(source, Mapping): msg = "Country SPM receipt must be an object" - raise TypeError(msg) + raise ValueError(msg) # noqa: TRY004 # only ValueError means an uncertifiable receipt, as in the Public API return _compact_receipt(selection, source) @@ -377,7 +395,7 @@ def _calculation_receipt(simulation: object) -> SPMProvenance | None: return None try: return _country_receipt(simulation) - except (TypeError, ValueError) as error: + except ValueError as error: raise SPMInputError( _CONFIGURATION_UNAVAILABLE, "The installed country model reported an SPM receipt this API cannot " diff --git a/projects/policyengine-simulation-executor/tests/test_household_spm.py b/projects/policyengine-simulation-executor/tests/test_household_spm.py index 1a3cd975a..b20263705 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_spm.py +++ b/projects/policyengine-simulation-executor/tests/test_household_spm.py @@ -243,6 +243,76 @@ def test_an_uncertifiable_country_receipt_is_an_spm_error() -> None: ) +_UNCERTIFIABLE = ( + "The installed country model reported an SPM receipt this API cannot certify: " +) + + +def _receipt_without(*names: str, **overrides: object) -> dict: + receipt = _country_receipt(**overrides) + for name in names: + del receipt[name] + return receipt + + +@pytest.mark.parametrize( + ("receipt", "reason"), + [ + # Each required text is checked before the receipt is compared. + ( + _receipt_without("scenario", forecast_sha256="b" * 64), + "Country SPM receipt requires scenario", + ), + ( + _receipt_without("geography_kind", scenario="flat"), + "Country SPM receipt requires geography_kind", + ), + ( + _country_receipt(years=["2026"]), + "Country SPM receipt requires a years mapping", + ), + ( + _country_receipt(years={2026: {}}), + "Country SPM receipt years must be strings", + ), + # Runtime versions are checked before the forecast ID. + ( + _receipt_without("runtime_versions", "forecast_id"), + "Country SPM receipt requires runtime_versions", + ), + ( + _receipt_without("forecast_id"), + "Country SPM receipt requires forecast_id", + ), + ( + _country_receipt(years={"26": {}}), + "years: years must contain four-digit calendar years", + ), + ], +) +def test_uncertifiable_receipts_are_worded_as_the_public_api_words_them( + receipt, reason +) -> None: + with pytest.raises(SPMInputError) as raised: + household_spm.household_spm_provenance( + FakeSimulation(receipt=receipt), DEFAULTS + ) + + assert raised.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert raised.value.message == _UNCERTIFIABLE + reason + + +def test_a_receipt_that_is_not_an_object_is_uncertifiable() -> None: + with pytest.raises(SPMInputError) as raised: + household_spm.household_spm_provenance( + FakeSimulation(receipt=["not", "an", "object"]), DEFAULTS + ) + + assert ( + raised.value.message == _UNCERTIFIABLE + "Country SPM receipt must be an object" + ) + + def test_a_receipt_for_another_selection_is_rejected() -> None: with pytest.raises(ValueError, match="differs from the request"): household_spm.household_spm_provenance( From d9462a3b53149fe1e3414f81597d5115463f07ae Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:06:15 +0400 Subject: [PATCH 015/124] Check a household's SPM settings exactly as sent, with the Public API's codes The request's spm field now carries the caller's SPM settings as any JSON value, and the household function checks them as the Public API's routes do: settings for a country other than the US are unsupported, an explicit null for the US is invalid, and malformed settings are invalid with the Public API's message, before the bundle's certification is read. A metro selection needs an area and any other kind accepts none, judged on the settings as sent, as the Public API's model judges them. Before, a malformed selection or an explicit null failed the request's validation instead of returning a typed SPM failure. The request's documentation now says which parts of a saved household's calculation match the Public API's stored-household route (the calculated household, warnings, SPM receipt and SPM codes) and which do not (two error messages, which that route words with the household and policy IDs). Co-Authored-By: Claude Opus 5.5 --- .../household_execution.py | 68 +++++++++---------- .../tests/test_household_execution.py | 16 ++++- .../household_calculation.py | 1 + .../household_spm.py | 66 +++++++++++++----- .../tests/test_household_calculation.py | 29 +++++++- .../tests/test_household_spm.py | 50 ++++++++++++-- 6 files changed, 169 insertions(+), 61 deletions(-) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py index df1f17be9..254df8be1 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py @@ -33,9 +33,9 @@ from pydantic import ( AfterValidator, BaseModel, - BeforeValidator, ConfigDict, Field, + JsonValue, PrivateAttr, TypeAdapter, field_validator, @@ -47,7 +47,6 @@ SPMErrorDetail, SPMProvenance, SPMResolvedConfiguration, - SPMSelection, ) from policyengine_simulation_contract.stage12_bundle import ( CountryId, @@ -416,20 +415,6 @@ def _unique_policy_keys( ] -def _reject_explicit_null_spm(value: object) -> object: - # Validators do not run on the default, so only an explicit null gets here. - if value is None: - msg = "spm must be an object; omit it to inherit the certified defaults." - raise ValueError(msg) - return value - - -SPMChoice = Annotated[ - SPMSelection | None, - BeforeValidator(_reject_explicit_null_spm), -] - - class HouseholdBundleProvenance(StrictHouseholdModel): """The PolicyEngine.py bundle a household calculation runs with. @@ -484,20 +469,33 @@ class HouseholdExecutionRequest(StrictHouseholdModel): ``add_missing_variables`` adds every yearly, monthly and eternal variable the household's entities lack before calculating, as the Public API's - ``/calculate-full`` route does. ``spm`` selects SPM settings for a US - household: omit it to inherit the bundle's certified defaults; supplying - it, even as an empty object, is a choice, and then a missing SPM input is - an error rather than an uncalculated value. ``spm_selection_source`` says - whether the selection came from this request or from a saved household, - which changes only how an SPM forecast the bundle no longer carries is - reported. - - To calculate a saved household as the Public API's stored-household - route (``GET /{country}/household/{id}/policy/{policy_id}``) does, set - ``add_missing_variables``, because that route always adds missing - variables; pass the household's saved SPM selection, if it has one, as - ``spm``, so it counts as chosen; and set ``spm_selection_source`` to - ``"saved_household"``. + ``/calculate-full`` route does. ``spm`` carries the caller's SPM settings + exactly as sent, as any JSON value, and the household function checks + them with the Public API's SPM error codes: settings for a country other + than the US are unsupported, and for the US an explicit null or malformed + settings are invalid. Omit ``spm`` to inherit the bundle's certified + defaults; sending it, even as an empty object, is a choice, and then a + missing SPM input is an error rather than an uncalculated value. + ``spm_selection_source`` says whether the settings came from this request + or from a saved household, which changes only how an SPM forecast the + bundle no longer carries is reported. + + To calculate a saved household with the inputs the Public API's + stored-household route (``GET /{country}/household/{id}/policy/{policy_id}``) + uses, set ``add_missing_variables``, because that route always adds + missing variables; pass the household's saved SPM selection as ``spm`` + only if it has one (a saved null is no choice); and set + ``spm_selection_source`` to ``"saved_household"``. The calculated + household, warnings, SPM receipt and SPM error codes then match that + route's. Its error messages do not: the route reports a household it + cannot read, and any other failure except an SPM error, as an error + calculating that household ID under that policy ID, while a household + function reports the request routes' wording (``invalid_household`` and + ``calculation_failed``), so a caller that serves the stored-household + route rewrites those two messages. + + These models check the request's structure; a request they reject (for + example a group without ``members``) is the caller's to report. """ schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION @@ -506,7 +504,7 @@ class HouseholdExecutionRequest(StrictHouseholdModel): household: HouseholdDocument policies: Policies add_missing_variables: bool = False - spm: SPMChoice = None + spm: JsonValue = None spm_selection_source: Literal["request", "saved_household"] = "request" bundle: HouseholdBundleProvenance @@ -514,16 +512,16 @@ class HouseholdExecutionRequest(StrictHouseholdModel): def serialize_request( self, handler: SerializerFunctionWrapHandler ) -> dict[str, Any]: - """Leave out ``spm`` when no selection was made, so omission is kept.""" + """Leave out ``spm`` when the caller sent none, so omission is kept.""" dumped: dict[str, Any] = handler(self) - if self.spm is None: + if not self.spm_requested: dumped.pop("spm", None) return dumped @property def spm_requested(self) -> bool: - """Whether the caller chose SPM settings rather than inheriting the defaults.""" - return self.spm is not None + """Whether the caller sent SPM settings rather than inheriting the defaults.""" + return "spm" in self.model_fields_set class HouseholdPolicyResult(StrictHouseholdModel): diff --git a/libs/policyengine-simulation-contract/tests/test_household_execution.py b/libs/policyengine-simulation-contract/tests/test_household_execution.py index 3d1ef4f2c..ce7aac034 100644 --- a/libs/policyengine-simulation-contract/tests/test_household_execution.py +++ b/libs/policyengine-simulation-contract/tests/test_household_execution.py @@ -330,9 +330,19 @@ def test_an_empty_spm_selection_is_still_a_choice() -> None: assert HouseholdExecutionRequest.model_validate(dumped).spm_requested is True -def test_an_explicit_null_spm_is_rejected() -> None: - with pytest.raises(ValidationError, match="omit it"): - _request(spm=None) +def test_spm_settings_travel_exactly_as_the_caller_sent_them() -> None: + # The household function checks them, so a malformed selection or an + # explicit null fails with the Public API's SPM error codes. + for sent in (None, {"geography_kind": "planet"}, "national"): + request = _request(spm=sent) + + assert request.spm == sent + assert request.spm_requested is True + dumped = request.model_dump(mode="json") + assert dumped["spm"] == sent + transported = HouseholdExecutionRequest.model_validate(dumped) + assert transported.spm == sent + assert transported.spm_requested is True def test_a_request_rejects_unknown_fields_and_other_schema_versions() -> None: diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py index 9a9b29c26..647fed5c6 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -532,6 +532,7 @@ def _resolved_spm_selection( return resolve_household_spm_selection( request.country, request.spm, + sent=request.spm_requested, saved_household=request.spm_selection_source == "saved_household", ) except SPMInputError as error: diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py index 56860cee3..94437a276 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py @@ -3,8 +3,9 @@ This reproduces the Public API's household SPM handling, so that a v2 household result and error carry the same SPM codes, messages and receipt: -- ``resolve_household_spm_selection`` merges a caller's selection over the - installed bundle's certified defaults, with the Public API's error codes; +- ``resolve_household_spm_selection`` checks a caller's settings, exactly as + sent, and merges them over the installed bundle's certified defaults, with + the Public API's error codes; - ``household_spm_error_detail`` recognizes the SPM errors that end a calculation; - ``household_spm_provenance`` reduces the country model's receipt to the @@ -250,34 +251,67 @@ def _resolve_certified_selection( return resolved.model_dump(mode="json") +def _requested_selection(selection: object) -> SPMSelection: + """Read the caller's settings; raise SPM_SETTINGS_INVALID if they are malformed. + + As in the Public API, the area rule applies to the settings as sent, with + an omitted geography kind read as county: a metro selection needs an area + and any other kind accepts none. The contract's model checks that rule + only once both fields are set, because Stage 12 requests inherit omitted + fields first. + """ + try: + chosen = SPMSelection.model_validate({} if selection is None else selection) + except ValidationError as error: + raise SPMInputError(_SETTINGS_INVALID, _readable_error(error)) from error + if chosen.geography_kind == "metro": + if not chosen.geography_id or not chosen.geography_id.strip(): + raise SPMInputError( + _SETTINGS_INVALID, "An SPM area selection requires geography_id" + ) + elif chosen.geography_id is not None: + raise SPMInputError( + _SETTINGS_INVALID, "Only an SPM area selection accepts geography_id" + ) + return chosen + + def resolve_household_spm_selection( country: str, - selection: SPMSelection | None, + selection: object, *, + sent: bool, saved_household: bool, ) -> dict[str, Any] | None: - """Resolve a household's SPM selection against the installed bundle. - - ``selection`` is None when the caller made no choice. Returns the complete - resolved selection, or None when no SPM applies (any country but the US, - or a bundle whose US model predates canonical SPM). Raises SPMInputError - with the Public API's code and message otherwise. ``saved_household`` says - the selection was read from a saved household, which changes only how a - forecast hash the bundle no longer carries is reported. + """Resolve a household's SPM settings, exactly as sent, against the bundle. + + ``sent`` says whether the caller sent settings at all; ``selection`` is + what it sent. Returns the complete resolved selection, or None when no SPM + applies (any country but the US, or a bundle whose US model predates + canonical SPM). Raises SPMInputError with the Public API's code and + message otherwise, checking in its order: settings for another country, + an explicit null, malformed settings, then the bundle's certification. + ``saved_household`` says the settings were read from a saved household, + which changes only how a forecast hash the bundle no longer carries is + reported. """ if country != "us": - if selection is not None: + if sent: raise SPMInputError( _SETTINGS_UNSUPPORTED, "SPM settings are only available for the US" ) return None - configured = _certified_configuration(selection_sent=selection is not None) + if sent and selection is None: + raise SPMInputError( + _SETTINGS_INVALID, + "spm must be an object; omit it to inherit the certified defaults.", + ) + chosen = _requested_selection(selection) + configured = _certified_configuration(selection_sent=sent) if configured is None: return None return _resolve_certified_selection( - selection if selection is not None else SPMSelection(), - configured, - saved_household=saved_household, + chosen, configured, saved_household=saved_household ) diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py index c6874b31e..1ae011b7f 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_calculation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -278,7 +278,7 @@ def test_an_spm_input_error_fails_only_a_chosen_measurement(monkeypatch) -> None monkeypatch.setattr( household_calculation, "resolve_household_spm_selection", - lambda country, selection, *, saved_household: dict(resolved), + lambda country, selection, *, sent, saved_household: dict(resolved), ) monkeypatch.setattr( household_calculation, @@ -324,7 +324,7 @@ def __init__(self, **options: object) -> None: monkeypatch.setattr( household_calculation, "resolve_household_spm_selection", - lambda country, selection, *, saved_household: resolved, + lambda country, selection, *, sent, saved_household: resolved, ) def record_receipt(simulation, expected_selection): @@ -353,7 +353,7 @@ def test_an_spm_configuration_error_fails_even_an_inherited_measurement( monkeypatch.setattr( household_calculation, "resolve_household_spm_selection", - lambda country, selection, *, saved_household: { + lambda country, selection, *, sent, saved_household: { "forecast_content_sha256": "a" * 64, "scenario": "ce_trend", "geography_kind": "national", @@ -411,6 +411,29 @@ def failing_simulation(**_: object): assert outcome.calculation_id == CALCULATION_ID +@pytest.mark.parametrize( + ("country", "settings", "spm_code"), + [ + ("uk", None, "SPM_SETTINGS_UNSUPPORTED"), + ("us", None, "SPM_SETTINGS_INVALID"), + ("us", {"geography_kind": "planet"}, "SPM_SETTINGS_INVALID"), + ], +) +def test_spm_settings_as_sent_fail_with_public_api_codes( + country, settings, spm_code +) -> None: + outcome = _calculate_with_stand_in( + _stand_in_simulation({}), country=country, spm=settings + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.spm_error is not None + assert outcome.error.spm_error.code == spm_code + assert outcome.error.message == outcome.error.spm_error.message + assert outcome.error.policy_key is None + + def test_a_receipt_that_cannot_be_certified_is_an_spm_failure(monkeypatch) -> None: def uncertifiable(simulation, expected_selection): code = "SPM_CONFIGURATION_UNAVAILABLE" diff --git a/projects/policyengine-simulation-executor/tests/test_household_spm.py b/projects/policyengine-simulation-executor/tests/test_household_spm.py index b20263705..919f09ba6 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_spm.py +++ b/projects/policyengine-simulation-executor/tests/test_household_spm.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from policyengine_simulation_contract.spm import SPMInputError, SPMSelection +from policyengine_simulation_contract.spm import SPMInputError from policyengine_simulation_executor import household_spm FORECAST_SHA = "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173" @@ -50,15 +50,20 @@ def certified_bundle(monkeypatch): monkeypatch.setattr(household_spm, "_forecast", lambda sha: FakeForecast()) -def _resolve(selection=None, *, country="us", saved_household=False): +# Stands for a request that sends no SPM settings at all. +OMITTED = object() + + +def _resolve(selection=OMITTED, *, country="us", saved_household=False): return household_spm.resolve_household_spm_selection( country, - None if selection is None else SPMSelection.model_validate(selection), + None if selection is OMITTED else selection, + sent=selection is not OMITTED, saved_household=saved_household, ) -def _error(selection=None, **options) -> SPMInputError: +def _error(selection=OMITTED, **options) -> SPMInputError: with pytest.raises(SPMInputError) as raised: _resolve(selection, **options) return raised.value @@ -86,6 +91,43 @@ def test_other_countries_accept_no_selection(certified_bundle) -> None: ) +def test_an_explicit_null_is_invalid_for_the_us_and_unsupported_elsewhere( + certified_bundle, +) -> None: + us_error = _error(None) + uk_error = _error(None, country="uk") + + assert (us_error.code, us_error.message) == ( + "SPM_SETTINGS_INVALID", + "spm must be an object; omit it to inherit the certified defaults.", + ) + assert (uk_error.code, uk_error.message) == ( + "SPM_SETTINGS_UNSUPPORTED", + "SPM settings are only available for the US", + ) + + +@pytest.mark.parametrize( + ("selection", "message"), + [ + ({"geography_kind": "metro"}, "An SPM area selection requires geography_id"), + ({"geography_id": "31080"}, "Only an SPM area selection accepts geography_id"), + ("national", "Input should be a valid dictionary or instance of SPMSelection"), + ], +) +def test_a_malformed_selection_is_invalid_before_the_bundle_is_read( + monkeypatch, selection, message +) -> None: + # As in the Public API, even a bundle without SPM support reports the + # settings' own problem first. + monkeypatch.setattr(household_spm, "_bundle_spm_configuration", lambda: None) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: False) + + error = _error(selection) + + assert (error.code, error.message) == ("SPM_SETTINGS_INVALID", message) + + def test_a_foreign_artifact_hash_is_reported_by_where_it_came_from( certified_bundle, ) -> None: From db610916aeaa98b244a63ea8f4b169300a8cb766 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:06:50 +0400 Subject: [PATCH 016/124] Load the household SPM forecast without the main path's SPM module The household SPM code now loads the certified forecast with its own per-process loader instead of a wrapper added to the main path's SPM module, which returns to its state on main. The household path and the main path share no SPM code. Co-Authored-By: Claude Opus 5.5 --- .../household_spm.py | 16 ++++++++++++---- .../src/policyengine_simulation_executor/spm.py | 9 --------- .../tests/test_household_spm.py | 12 ++++++------ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py index 94437a276..cf5b05817 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py @@ -16,6 +16,7 @@ import inspect from collections.abc import Mapping +from functools import lru_cache from typing import TYPE_CHECKING, Any, NoReturn from policyengine_simulation_contract.spm import ( @@ -115,10 +116,17 @@ def _us_model_supports_spm() -> bool: return _simulation_supports_spm(Simulation) -def _forecast(expected_sha256: str) -> SPMForecast: - from policyengine_simulation_executor.spm import certified_spm_forecast +@lru_cache(maxsize=4) +def certified_forecast(expected_sha256: str) -> SPMForecast: + """Return the packaged SPM forecast whose content has ``expected_sha256``. - return certified_spm_forecast(expected_sha256) + Loaded once per process and digest; raises when the packaged forecast has + another digest. The household path keeps this loader apart from the main + path's SPM module. + """ + from spm_calculator import load_forecast + + return load_forecast(expected_sha256=expected_sha256) def _certified_defaults(configured: object) -> tuple[SPMSelection, SPMForecast]: @@ -134,7 +142,7 @@ def _certified_defaults(configured: object) -> tuple[SPMSelection, SPMForecast]: if not _us_model_supports_spm(): msg = "The installed US model does not support canonical SPM" raise ValueError(msg) - return defaults, _forecast(defaults.forecast_content_sha256) + return defaults, certified_forecast(defaults.forecast_content_sha256) def _validated_selection(values: dict[str, Any], forecast: SPMForecast) -> SPMSelection: diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py index cbc29f387..b5c2490a7 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py @@ -34,15 +34,6 @@ def _forecast(expected_sha256: str | None) -> SPMForecast: return load_forecast(expected_sha256=expected_sha256) -def certified_spm_forecast(expected_sha256: str) -> SPMForecast: - """Return the packaged SPM forecast whose content has ``expected_sha256``. - - Loaded once per process and digest; raises when the packaged forecast has - another digest. - """ - return _forecast(expected_sha256) - - @lru_cache(maxsize=1) def runtime_spm_capability() -> SPMCapability | None: """Return the installed image's certified SPM capability. diff --git a/projects/policyengine-simulation-executor/tests/test_household_spm.py b/projects/policyengine-simulation-executor/tests/test_household_spm.py index 919f09ba6..d1053aa52 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_spm.py +++ b/projects/policyengine-simulation-executor/tests/test_household_spm.py @@ -47,7 +47,7 @@ def certified_bundle(monkeypatch): household_spm, "_bundle_spm_configuration", lambda: dict(DEFAULTS) ) monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) - monkeypatch.setattr(household_spm, "_forecast", lambda sha: FakeForecast()) + monkeypatch.setattr(household_spm, "certified_forecast", lambda sha: FakeForecast()) # Stands for a request that sends no SPM settings at all. @@ -207,7 +207,7 @@ def test_changing_the_geography_kind_drops_an_inherited_area(monkeypatch) -> Non lambda: {**DEFAULTS, "geography_kind": "metro", "geography_id": "31080"}, ) monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) - monkeypatch.setattr(household_spm, "_forecast", lambda sha: FakeForecast()) + monkeypatch.setattr(household_spm, "certified_forecast", lambda sha: FakeForecast()) assert _resolve({"geography_kind": "national"}) == { **DEFAULTS, @@ -398,9 +398,9 @@ def test_spm_errors_are_recognized_through_wrapping_exceptions() -> None: def test_the_certified_forecast_is_loaded_once_by_its_digest() -> None: - from policyengine_simulation_executor.spm import certified_spm_forecast + # The household path keeps its own loader, separate from the main path's + # SPM module. + forecast = household_spm.certified_forecast(FORECAST_SHA) - forecast = certified_spm_forecast(FORECAST_SHA) - - assert certified_spm_forecast(FORECAST_SHA) is forecast + assert household_spm.certified_forecast(FORECAST_SHA) is forecast assert forecast.years From 85afc4e58d9cf86dfcc5a62460a6e2493a2dc943 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:07:12 +0400 Subject: [PATCH 017/124] List every known difference from the Public API in one place The household calculation module's documentation now lists each known difference from the Public API's household calculation, which were described in several places or not at all: how undefined numbers and dates are written, the UTC default year, an age given as one value under calculate-full, an explicit null axes, checking every policy's parameters first, the request routes' error wording, and requests the contract rejects. Co-Authored-By: Claude Opus 5.5 --- .../household_calculation.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py index 647fed5c6..0184783f4 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -12,10 +12,26 @@ the Public API's matching error responses. This keeps v2 results comparable with the Public API's results while both exist. -Two values are written differently, because the contract carries only finite -numbers and JSON scalars: an undefined number is the string "NaN" (the Public -API writes a bare ``NaN``, which is not JSON), and a date is the HTTP date -text the Public API's Flask encoder writes. +Every known difference from the Public API is listed here: + +- An undefined number is the string "NaN": the contract carries only finite + numbers and JSON scalars, and the Public API writes a bare ``NaN``, which + is not JSON. A date is the HTTP date text the Public API's Flask encoder + writes. +- The default household year, used when ``you`` has no age by period, is the + current UTC year; the Public API uses its host's local date. +- With ``add_missing_variables``, an age given for ``you`` as one value, not + by period, counts as no age; the Public API fails with an internal error. +- An explicit null ``axes`` means no axes, because the contract drops it; the + Public API treats the key's presence as a household with axes. +- Every policy's parameters are checked before any policy is calculated, so + an unknown parameter in a reform fails the whole request. +- Its messages are the request routes' (``/calculate`` and + ``/calculate-full``). The stored-household route words a household it + cannot read, and any other failure except an SPM error, with the household + and policy IDs, which a household function does not have. +- A request the contract's models reject, such as a group without + ``members``, never reaches this module; the caller reports it. """ from __future__ import annotations From 5c700a1ce98ce1cef54b08df330bd91bbb8bb389 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:09:50 +0400 Subject: [PATCH 018/124] Test publishing after a publisher released before schema version 2 A review asked whether a publish after code released before schema version 2 (as a deployed revert would run) could start from a stale schema version 2 document and drop what that code wrote. The schema version 2 reader already uses the document only while it is current; these tests pin that down for a publish: it keeps an application the older code redeployed without household functions, it takes the default from what the older code wrote, and older keys that a household-only schema version 2 publish left alone change nothing. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_stage12_manifest.py | 93 +++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py index cd7bccfe7..5d02bf4dc 100644 --- a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py +++ b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py @@ -23,6 +23,7 @@ Stage12CacheRelease, V2CountryWorker, V2ManifestLoader, + V2VersionManifest, V2WorkerValidation, V2WorkerVersion, assert_separate_manifest_names, @@ -477,6 +478,90 @@ def test_a_household_only_country_leaves_schema_1_readers_on_their_manifest() -> assert worker.economy_worker("us") is not None +def _validated_on(worker: V2WorkerVersion, day: int) -> V2WorkerVersion: + """Return ``worker`` as validated on ``day`` of September 2026.""" + validation = worker.validation.model_copy( + update={"validated_at": datetime(2026, 9, day, tzinfo=UTC)} + ) + return worker.model_copy(update={"validation": validation}) + + +def _write_as_a_schema_1_publisher( + store: FakeStore, *workers: V2WorkerVersion, default_version: str +) -> None: + """Write the older keys as a publisher released before schema 2 writes them.""" + manifest = V2VersionManifest( + generation="older-publisher", + default_version=default_version, + versions={worker.bundle.policyengine_version: worker for worker in workers}, + ) + store[ACTIVE_MANIFEST_KEY] = schema_1_manifest_payload(manifest, include_cache=True) + store[PRE_CACHE_MANIFEST_KEY] = schema_1_manifest_payload( + manifest, include_cache=False + ) + + +def test_a_publish_keeps_what_an_older_publisher_wrote_after_it() -> None: + # A deployment of code released before schema 2 (for example after a + # revert) redeploys 5.2.0 without household functions and writes only the + # older keys. The next schema 2 publish must start from that, not from + # its own stale document. + store = FakeStore() + publish_v2_manifest( + store=store, + worker=_validated_on(_worker(with_household_functions=True), 14), + ) + _write_as_a_schema_1_publisher( + store, _validated_on(_worker(), 20), default_version="5.2.0" + ) + + manifest = publish_v2_manifest( + store=store, + worker=_validated_on(_worker("5.3.0", with_household_functions=True), 25), + ) + + assert sorted(manifest.versions) == ["5.2.0", "5.3.0"] + assert manifest.versions["5.2.0"].household_worker("us") is None + assert manifest.versions["5.2.0"].validation.validated_at.day == 20 + assert manifest.default_version == "5.3.0" + + +def test_a_publish_keeps_the_default_of_the_latest_document() -> None: + store = FakeStore() + publish_v2_manifest(store=store, worker=_validated_on(_worker("5.3.0"), 14)) + # An older publisher later made 5.2.0 the default. + _write_as_a_schema_1_publisher( + store, + _validated_on(_worker("5.3.0"), 14), + _validated_on(_worker("5.2.0"), 20), + default_version="5.2.0", + ) + + manifest = publish_v2_manifest( + store=store, worker=_validated_on(_worker("5.2.5"), 25) + ) + + # 5.2.5 is newer than the latest default, 5.2.0, so it becomes the default. + assert manifest.default_version == "5.2.5" + assert sorted(manifest.versions) == ["5.2.0", "5.2.5", "5.3.0"] + + +def test_older_keys_left_behind_by_a_schema_2_publish_change_nothing() -> None: + store = FakeStore({ACTIVE_MANIFEST_KEY: _schema_1_document(include_cache=True)}) + publish_v2_manifest( + store=store, worker=_validated_on(_worker(household_only=("uk",)), 14) + ) + + manifest = publish_v2_manifest( + store=store, + worker=_validated_on(_worker("5.3.0", household_only=("uk",)), 25), + ) + + assert sorted(manifest.versions) == ["5.1.0", "5.2.0", "5.3.0"] + assert manifest.versions["5.2.0"].economy_worker("uk") is None + assert manifest.default_version == "5.3.0" + + def test_parse_v2_manifest_rejects_unknown_schema_versions() -> None: document = _schema_1_document(include_cache=True) document["schema_version"] = 3 @@ -701,14 +786,6 @@ def test_a_schema_2_document_left_behind_by_an_older_publish_is_not_read() -> No assert store.values[SCHEMA_2_MANIFEST_KEY] != stale -def _validated_on(worker: V2WorkerVersion, day: int) -> V2WorkerVersion: - """Return ``worker`` as validated on ``day`` of September 2026.""" - validation = worker.validation.model_copy( - update={"validated_at": datetime(2026, 9, day, tzinfo=UTC)} - ) - return worker.model_copy(update={"validation": validation}) - - def test_a_household_only_manifest_is_read_until_an_older_publish_follows() -> None: # Schema version 1 cannot describe a household-only country, so its # publish leaves the older keys alone; a later validation under them From fd45e2613b01a5b0dd7fa894f931eaf0675a22df Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:11:14 +0400 Subject: [PATCH 019/124] Resolve a comparison report's simulation callable as a typed text A v2 worker version now returns the simulation callable of an economy-capable country, and raises LookupError for any other country. The Simulation Entrypoint's comparison backend stores that callable on the prepared report and sends it to the coordinator, instead of looking up the country's worker by matching and sending a value that schema version 2 allows to be null. The adapter already skips a country that is not economy-capable, so a prepared report always has one. Co-Authored-By: Claude Opus 5.5 --- .../stage12_manifest.py | 14 ++++++ .../tests/test_stage12_manifest.py | 10 ++++ .../stage12_backend.py | 47 ++++++------------- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py index 4cb9562b6..1f935a9f1 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py @@ -212,6 +212,20 @@ def economy_worker(self, country: str) -> V2CountryWorker | None: None, ) + def economy_simulation_callable(self, country: str) -> str: + """Return the callable that runs one of ``country``'s report simulations. + + Raises LookupError when the manifest does not mark ``country`` as + economy-capable. + """ + worker = self.economy_worker(country) + if worker is None or worker.single_simulation_callable is None: + msg = ( + f"country {country!r} is not economy-capable in {self.application_name}" + ) + raise LookupError(msg) + return worker.single_simulation_callable + def household_worker(self, country: str) -> V2CountryWorker | None: """Return ``country``'s worker when it has a household function.""" return next( diff --git a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py index 5d02bf4dc..47c28b6fd 100644 --- a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py +++ b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py @@ -562,6 +562,16 @@ def test_older_keys_left_behind_by_a_schema_2_publish_change_nothing() -> None: assert manifest.default_version == "5.3.0" +def test_only_an_economy_capable_country_names_a_simulation_callable() -> None: + worker = _worker(household_only=("uk",)) + + assert worker.economy_simulation_callable("us") == "run_single_simulation_us" + with pytest.raises(LookupError, match="'uk' is not economy-capable"): + worker.economy_simulation_callable("uk") + with pytest.raises(LookupError, match="'fr' is not economy-capable"): + worker.economy_simulation_callable("fr") + + def test_parse_v2_manifest_rejects_unknown_schema_versions() -> None: document = _schema_1_document(include_cache=True) document["schema_version"] = 3 diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py index a1d232941..ed399fb56 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py @@ -20,7 +20,6 @@ ResultComparisonStatus, ) from policyengine_simulation_contract.stage12_manifest import ( - V2CountryWorker, V2ManifestLoader, V2WorkerVersion, ) @@ -100,12 +99,16 @@ class ReportOrigin: @dataclass(frozen=True) class PreparedReport: - """A comparison run ready to dispatch: its parent row, inputs and worker.""" + """A comparison run ready to dispatch: its parent row, inputs and worker. + + ``simulation_callable`` runs one of the report country's simulations; the + country is economy-capable, or the adapter would have skipped it. + """ parent: ComparisonReportRecord report: ReportExecutionInput worker_version: ResolvedWorkerVersion - country_worker: V2CountryWorker + simulation_callable: str class TemporaryStage12UnsupportedRequestError(ValueError): @@ -291,7 +294,6 @@ def _resolve_report( ) -> tuple[ ReportExecutionInput | None, ResolvedWorkerVersion, - V2CountryWorker, str | None, ]: worker_version = ResolvedWorkerVersion( @@ -303,21 +305,8 @@ def _resolve_report( evaluation_id=evaluation_id, worker=worker, ) - country = request_payload.get("country") - bundle_country = next( - (item for item in worker.bundle.countries if item.country == country), - worker.bundle.countries[0], - ) - country_worker = next( - item for item in worker.countries if item.country == bundle_country.country - ) skip_reason = adapted.skip_reason.value if adapted.skip_reason else None - return ( - adapted.report, - worker_version, - country_worker, - skip_reason, - ) + return adapted.report, worker_version, skip_reason def _parent_record( self, @@ -365,7 +354,7 @@ def _prepare_temporary_report( request_id: str, ) -> PreparedReport: evaluation_id = uuid4() - report, worker_version, country_worker, skip_reason = self._resolve_report( + report, worker_version, skip_reason = self._resolve_report( request_payload=request_payload, evaluation_id=evaluation_id, ) @@ -388,7 +377,9 @@ def _prepare_temporary_report( parent=parent, report=report, worker_version=worker_version, - country_worker=country_worker, + simulation_callable=worker_version.worker.economy_simulation_callable( + str(request_payload.get("country")) + ), ) def _prepare_automatic_report( @@ -433,14 +424,6 @@ def _prepare_automatic_report( }, ) return None - country = request_payload.get("country") - bundle_country = next( - (item for item in worker.bundle.countries if item.country == country), - worker.bundle.countries[0], - ) - country_worker = next( - item for item in worker.countries if item.country == bundle_country.country - ) parent = self._parent_record( evaluation_id=evaluation_id, origin=ReportOrigin( @@ -456,7 +439,9 @@ def _prepare_automatic_report( parent=parent, report=adapted.report, worker_version=worker_version, - country_worker=country_worker, + simulation_callable=worker.economy_simulation_callable( + str(request_payload.get("country")) + ), ) async def _spawn_report( @@ -519,9 +504,7 @@ async def _spawn_report( ), "worker_version": version, "modal_application": worker.application_name, - "simulation_callable": ( - prepared.country_worker.single_simulation_callable - ), + "simulation_callable": prepared.simulation_callable, "version_manifest_sha256": manifest_sha256, "bundle_manifest_sha256": worker.bundle_manifest_sha256, "artifact_prefix": artifact_prefix, From 615955f16ba7954eb28d2db6cf1fd6dc87de6ff0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:13:03 +0400 Subject: [PATCH 020/124] Test a chosen US SPM selection and the validation household with real models Two real-model tests join the PR checks: a US household without a county leaves the SPM threshold uncalculated under the inherited county selection, fails with SPM_GEOGRAPHY_REQUIRED when the selection is chosen, and is calculated with a national receipt when the national kind is chosen; and the installed US and UK models calculate the household that release validation and the image check send. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_household_calculation.py | 45 +++++++++++++++++++ .../tests/test_household_validation.py | 18 ++++++++ 2 files changed, 63 insertions(+) diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py index 1ae011b7f..1abc55163 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_calculation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -663,6 +663,51 @@ def test_us_calculate_full_adds_and_calculates_every_missing_variable() -> None: ) +US_SINGLE_WITHOUT_COUNTY = { + "people": {"you": {"age": {"2026": 35}, "employment_income": {"2026": 30_000}}}, + "spm_units": { + "your spm unit": { + "members": ["you"], + "spm_unit_spm_threshold": {"2026": None}, + } + }, + "households": { + "your household": {"members": ["you"], "state_name": {"2026": "TX"}} + }, +} + + +def test_us_a_chosen_spm_selection_must_have_its_geography() -> None: + # The certified defaults measure by county. Inherited, a household without + # a county leaves the SPM threshold uncalculated; chosen, it is an error. + def calculate(**spm_settings): + return calculate_household( + _request("us", US_SINGLE_WITHOUT_COUNTY, **spm_settings), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(today=TODAY), + ) + + inherited = calculate() + chosen = calculate(spm={}) + national = calculate(spm={"geography_kind": "national"}) + + assert isinstance(inherited, HouseholdExecutionResult), inherited + unit = inherited.output.results[0].household.to_situation()["spm_units"] + assert unit["your spm unit"]["spm_unit_spm_threshold"] == {"2026": None} + assert isinstance(chosen, HouseholdExecutionFailure) + assert chosen.error.code == "invalid_spm_selection" + assert chosen.error.spm_error is not None + assert chosen.error.spm_error.code == "SPM_GEOGRAPHY_REQUIRED" + assert isinstance(national, HouseholdExecutionResult), national + unit = national.output.results[0].household.to_situation()["spm_units"] + assert isinstance(unit["your spm unit"]["spm_unit_spm_threshold"]["2026"], float) + assert national.output.spm_config is not None + assert national.output.spm_config.geography_kind == "national" + receipt = national.output.results[0].spm_provenance + assert receipt is not None + assert receipt.geography_kind == "national" + + UK_SINGLE = { "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, "benunits": {"your benefit unit": {"members": ["you"]}}, diff --git a/projects/policyengine-simulation-executor/tests/test_household_validation.py b/projects/policyengine-simulation-executor/tests/test_household_validation.py index a82abb2df..d49d53ed0 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_validation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_validation.py @@ -10,6 +10,7 @@ HouseholdWorkerIdentity, ) from policyengine_simulation_executor.household_calculation import ( + calculate_household, installed_household_bundle, ) from policyengine_simulation_executor.household_validation import ( @@ -117,3 +118,20 @@ def test_an_outcome_that_is_not_the_calculated_household_fails_the_check( check_household_validation( change_outcome(_succeeded(request)), request=request, worker=WORKER ) + + +@pytest.mark.parametrize("country", ["us", "uk"]) +def test_the_installed_models_calculate_the_validation_household(country) -> None: + # Release validation and the image check depend on this household, so the + # PR checks calculate it with the installed country models too. + request = household_validation_request(country, year=2026) + worker = WORKER.model_copy( + update={"function_name": f"calculate_household_{country}"} + ) + + outcome = calculate_household(request, worker=worker, required_country=country) + + net_income = check_household_validation( + outcome.model_dump(mode="json"), request=request, worker=worker + ) + assert net_income > 0 From 7455b2406b733fca136553477bf3feac859abae2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:49:36 +0400 Subject: [PATCH 021/124] Measure the whole country model load in its own stage A US household calculation resolved its SPM selection before the model-load span opened, and that resolution imports the US model, so a cold container's model load was measured in none of the household stages. The country model now loads first, in its own span, and the SPM selection is resolved while the household is prepared, as the Public API resolves it while preparing a calculation. A test checks both, and that a saved household's settings are resolved as saved. The module's list of differences from the Public API now says that a failure under any one policy returns one failure for the whole request. Co-Authored-By: Claude Opus 5.5 --- .../household_calculation.py | 15 ++++-- .../tests/test_household_calculation.py | 53 ++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py index 0184783f4..576944a2d 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -25,7 +25,11 @@ - An explicit null ``axes`` means no axes, because the contract drops it; the Public API treats the key's presence as a household with axes. - Every policy's parameters are checked before any policy is calculated, so - an unknown parameter in a reform fails the whole request. + an unknown parameter in a reform fails the whole request. Likewise any + failure under one policy, such as a reform value that cannot be converted + or an SPM error only the reform causes, returns one failure for the whole + request, without the policies already calculated; the Public API's + separate request for each policy would still return the others. - Its messages are the request routes' (``/calculate`` and ``/calculate-full``). The stored-household route words a household it cannot read, and any other failure except an SPM error, with the household @@ -610,17 +614,20 @@ def _calculate_output( ) -> HouseholdCalculationOutput: """Calculate every policy of ``request``, raising a typed failure. - Checks run in the Public API's order: the bundle, the SPM selection, the - household's and policies' names, then each policy. + After the bundle check and loading the country model, checks run in the + Public API's order: the SPM selection, the household's and policies' + names, then each policy. The model loads first so its span measures the + whole load, which for the US otherwise happens while the SPM selection is + resolved. """ if request.bundle != bundle: raise _bundle_mismatch_failure(bundle) - spm_selection = _resolved_spm_selection(request) country = {"country": request.country} runtime = dependencies.runtime with _stage_scope(runtime, Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, country): model = dependencies.model_loader(request.country) with _stage_scope(runtime, Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, country): + spm_selection = _resolved_spm_selection(request) prepared = _prepared_household( request, model, diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py index 1abc55163..bc9a667c3 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_calculation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -61,15 +61,20 @@ def _request(country: str, household: dict, **overrides) -> HouseholdExecutionRe class RecordingRuntime: - """Records the spans a calculation opens, in order.""" + """Records the spans a calculation opens, in order, and which are open.""" def __init__(self) -> None: self.spans: list[tuple[str, dict]] = [] + self.open_spans: list[str] = [] @contextmanager def span(self, name: str, *, attributes: dict | None = None): self.spans.append((name, dict(attributes or {}))) - yield + self.open_spans.append(name) + try: + yield + finally: + self.open_spans.pop() # A stand-in country model: enough of a country package to exercise the @@ -434,6 +439,50 @@ def test_spm_settings_as_sent_fail_with_public_api_codes( assert outcome.error.policy_key is None +@pytest.mark.parametrize( + ("source", "saved_household"), [("request", False), ("saved_household", True)] +) +def test_spm_settings_are_resolved_while_preparing_the_household( + monkeypatch, source, saved_household +) -> None: + # The country model loads first, under its own span, so the time the US + # model takes to import is measured there and not in SPM resolution. + runtime = RecordingRuntime() + events: list[tuple[str, object]] = [] + model = _stand_in_model(_stand_in_simulation({"net_income": np.array([1.0])})) + + def load_model(country): + events.append(("model", list(runtime.open_spans))) + return model + + def resolve(country, selection, *, sent, saved_household): + events.append(("spm", list(runtime.open_spans))) + events.append(("spm options", (selection, sent, saved_household))) + + monkeypatch.setattr( + household_calculation, "resolve_household_spm_selection", resolve + ) + household = { + "people": {"you": {"age": {"2026": 30}}}, + "households": {"home": {"members": ["you"], "net_income": {"2026": None}}}, + } + + outcome = calculate_household( + _request("us", household, spm={}, spm_selection_source=source), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies( + model_loader=load_model, today=TODAY, runtime=runtime + ), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + assert events == [ + ("model", ["v2_household_country_model_load"]), + ("spm", ["v2_household_input_normalization"]), + ("spm options", ({}, True, saved_household)), + ] + + def test_a_receipt_that_cannot_be_certified_is_an_spm_failure(monkeypatch) -> None: def uncertifiable(simulation, expected_selection): code = "SPM_CONFIGURATION_UNAVAILABLE" From b7601405df4a76651312855be183aa3a43329bb5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:04:43 +0400 Subject: [PATCH 022/124] Keep the shared SPM receipt model's wording and translate it for households The contract's SPM receipt model is shared with the main path, so it returns to its wording on main. The household SPM code now translates that model's year and metro-area errors into the Public API's wording when it reports a receipt it cannot certify, so household messages still match the Public API and nothing the main path runs changes. Co-Authored-By: Claude Opus 5.5 --- .../policyengine_simulation_contract/spm.py | 8 +++--- .../household_spm.py | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py index e666da24f..c2bddcae1 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/spm.py @@ -193,10 +193,10 @@ def serialize_provenance( # noqa: ANN201 # a return annotation would become th def validate_years(cls, value: tuple[str, ...]) -> tuple[str, ...]: """Require four-digit years, sorted ascending with no duplicates.""" if any(re.fullmatch(r"[0-9]{4}", year) is None for year in value): - msg = "years must contain four-digit calendar years" + msg = "SPM provenance years must use four-digit years" raise ValueError(msg) if value != tuple(sorted(set(value))): - msg = "years must be sorted and unique" + msg = "SPM provenance years must be sorted and unique" raise ValueError(msg) return value @@ -205,10 +205,10 @@ def validate_provenance_location(self) -> "SPMProvenance": """Require ``geography_id`` for metro receipts and forbid it otherwise.""" if self.geography_kind == "metro": if self.geography_id is None or not self.geography_id.strip(): - msg = "A metro SPM receipt requires geography_id" + msg = "An SPM area receipt requires geography_id" raise ValueError(msg) elif self.geography_id is not None: - msg = "Only a metro SPM receipt accepts geography_id" + msg = "Only an SPM area receipt accepts geography_id" raise ValueError(msg) return self diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py index cf5b05817..7f7ddad6d 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py @@ -51,8 +51,25 @@ _SETTINGS_UNSUPPORTED = "SPM_SETTINGS_UNSUPPORTED" +# The contract's SPM receipt model, which the main path shares, words these +# errors differently from the Public API's model. The household path reports +# the Public API's wording without changing the shared model. +_PUBLIC_API_RECEIPT_MESSAGES = { + "SPM provenance years must use four-digit years": ( + "years must contain four-digit calendar years" + ), + "SPM provenance years must be sorted and unique": "years must be sorted and unique", + "An SPM area receipt requires geography_id": ( + "A metro SPM receipt requires geography_id" + ), + "Only an SPM area receipt accepts geography_id": ( + "Only a metro SPM receipt accepts geography_id" + ), +} + + def _readable_error(error: BaseException) -> str: - """Render a validation error as a client-facing message, without its URL.""" + """Render a validation error as the Public API renders it, without its URL.""" if not isinstance(error, ValidationError): return str(error) return "; ".join( @@ -60,7 +77,7 @@ def _readable_error(error: BaseException) -> str: part for part in ( ".".join(str(item) for item in detail["loc"]), - detail["msg"].removeprefix("Value error, "), + _public_api_message(detail["msg"].removeprefix("Value error, ")), ) if part ) @@ -68,6 +85,10 @@ def _readable_error(error: BaseException) -> str: ) +def _public_api_message(message: str) -> str: + return _PUBLIC_API_RECEIPT_MESSAGES.get(message, message) + + def _current_bundle() -> object: """Return the installed PolicyEngine.py bundle manifest.""" from policyengine.bundle import get_current_bundle From 08f1301629205d8eefa86f25e0b3d726bc81e059 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:05:19 +0400 Subject: [PATCH 023/124] Test the manifest with released document shapes and document rollback limits The tests' older-publisher documents now carry only the fields those publishers wrote: no region datasets under the keys that predate them. Two tests join them: the fixtures hold only released fields, and a publish refuses an unreadable schema version 2 document. The migration document now says what a rollback to code released before schema version 2 costs once current code publishes again: the manifest names no household functions for versions published before the rollback until each is published again, and it loses what only the schema version 2 document held. It also says that current readers do not read a publish by code released before cache releases, so a rollback that far needs the newer keys deleted first; the region dataset readers on main already behave this way. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 10 ++++-- .../tests/test_stage12_manifest.py | 33 ++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index 23b84522b..ae9be88a0 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -426,8 +426,14 @@ it, with its region datasets removed, equals the manifest under `active` before the first publish that writes it. Code released before schema version 2 or before region datasets republishes only the older keys it knows, so after a rollback the out-of-date copies stop matching and are no longer read, -and the next publish starts from what the older code wrote. Removing the older -copies needs a later change, once no deployed or rollback revision reads them. +and the next publish starts from what the older code wrote. That document has +no household functions, so the manifest names none for versions published +before the rollback until each is published again, and it lacks whatever only +`active-schema-2` held, such as a version with a household-only country. Code +released before cache releases writes only `active`, which current readers do +not read while `active-with-cache` exists; a rollback that far needs the newer +keys deleted from the Modal Dict first. Removing the older copies needs a later +change, once no deployed or rollback revision reads them. A rollback to code released before region datasets has one window: the older worker is deployed before its publish job runs, and until that publish the diff --git a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py index 47c28b6fd..eb3b464af 100644 --- a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py +++ b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py @@ -398,6 +398,7 @@ def _schema_1_document(*, include_cache: bool, version: str = "5.1.0") -> dict: {"household_validation_invocation_ids"} | (set() if include_cache else {"cache"}) ), + "bundle": {"countries": {"__all__": {"region_datasets"}}}, **({} if include_cache else {"cache": True}), }, ) @@ -489,16 +490,14 @@ def _validated_on(worker: V2WorkerVersion, day: int) -> V2WorkerVersion: def _write_as_a_schema_1_publisher( store: FakeStore, *workers: V2WorkerVersion, default_version: str ) -> None: - """Write the older keys as a publisher released before schema 2 writes them.""" + """Write the keys a publisher released before region datasets writes.""" manifest = V2VersionManifest( generation="older-publisher", default_version=default_version, versions={worker.bundle.policyengine_version: worker for worker in workers}, ) - store[ACTIVE_MANIFEST_KEY] = schema_1_manifest_payload(manifest, include_cache=True) - store[PRE_CACHE_MANIFEST_KEY] = schema_1_manifest_payload( - manifest, include_cache=False - ) + store[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(manifest) + store[PRE_CACHE_MANIFEST_KEY] = pre_cache_manifest_payload(manifest) def test_a_publish_keeps_what_an_older_publisher_wrote_after_it() -> None: @@ -591,6 +590,30 @@ def test_loader_retains_its_last_valid_v2_document() -> None: assert loader.load() == expected +def test_a_publish_refuses_an_unreadable_schema_2_document() -> None: + store = FakeStore() + publish_v2_manifest(store=store, worker=_worker()) + store.values[SCHEMA_2_MANIFEST_KEY] = {"schema_version": 999} + + with pytest.raises(ValueError, match="schema_version"): + publish_v2_manifest(store=store, worker=_worker("5.3.0")) + + +@pytest.mark.parametrize("include_cache", [True, False]) +def test_older_publisher_fixtures_hold_only_released_fields( + include_cache: bool, # noqa: FBT001 # pytest passes parameters by name +) -> None: + # The documents these tests store as older publishers' output carry only + # the fields those publishers wrote. + document = _schema_1_document(include_cache=include_cache) + worker = _assert_schema_1_top_level( + {**document, "versions": {"5.2.0": document["versions"]["5.1.0"]}} + )["versions"]["5.2.0"] + for country in worker["bundle"]["countries"]: + assert set(country) == _COUNTRY_BUNDLE_FIELDS_BEFORE_REGION_DATASETS + assert ("cache" in worker) is include_cache + + def test_loader_never_falls_back_to_v1_state() -> None: v1_store = FakeStore({ACTIVE_MANIFEST_KEY: {"schema_version": 1}}) v2_store = FakeStore() From 5cc4ce93efcddff2b6b0c806945f823989360b1c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:00:44 +0400 Subject: [PATCH 024/124] Add the simulation persistence library with its schema names and engine Add libs/policyengine-simulation-persistence, the package that will hold the Simulation Entrypoint's own Postgres schema, "simulation". This commit holds only the names and the connection: - the schema name, the runtime role name, the naming convention and the metadata every table of the schema registers in; - the runtime engine, built from the canonical postgresql:// URL the deployment stores, with the psycopg 3 driver, a small pool, pre-ping, a connect timeout and no server-side prepared statements (so the same URL works through Supabase's poolers); - a reader for the applied Alembic revision that needs no Alembic. A malformed URL raises ValueError without repeating the URL or the parser's message, since the URL holds a password. The package is unit-tested in the PR test matrix and by make test, type-checked by the Pyright job under sim.quality.pyright-libraries-and-entry, and scanned by the database access guard. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yml | 2 + Makefile | 1 + docs/engineering/skills/type-checking.md | 6 +- docs/generated/rules.md | 8 +- .../README.md | 28 +++ .../pyproject.toml | 39 +++ .../__init__.py | 23 ++ .../engine.py | 76 ++++++ .../schema.py | 40 +++ .../tests/test_schema.py | 77 ++++++ .../uv.lock | 227 ++++++++++++++++++ .../tests/test_database_access_boundaries.py | 1 + rules/registry.toml | 4 +- 13 files changed, 524 insertions(+), 8 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/README.md create mode 100644 libs/policyengine-simulation-persistence/pyproject.toml create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/engine.py create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_schema.py create mode 100644 libs/policyengine-simulation-persistence/uv.lock diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0c27df2f1..805305f71 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,6 +51,7 @@ jobs: - projects/policyengine-simulation-gateway - libs/policyengine-simulation-contract - libs/policyengine-simulation-observability + - libs/policyengine-simulation-persistence - libs/policyengine-stage12-persistence - libs/policyengine-fastapi @@ -157,6 +158,7 @@ jobs: package: - libs/policyengine-simulation-contract - libs/policyengine-simulation-observability + - libs/policyengine-simulation-persistence - libs/policyengine-stage12-persistence - libs/policyengine-fastapi - projects/policyengine-simulation-entry diff --git a/Makefile b/Makefile index 94484d22c..7a0aea572 100644 --- a/Makefile +++ b/Makefile @@ -89,6 +89,7 @@ test: projects/policyengine-simulation-gateway \ libs/policyengine-simulation-contract \ libs/policyengine-simulation-observability \ + libs/policyengine-simulation-persistence \ libs/policyengine-stage12-persistence \ libs/policyengine-fastapi; do \ echo "Testing $$proj..."; \ diff --git a/docs/engineering/skills/type-checking.md b/docs/engineering/skills/type-checking.md index 56a444b58..b1b8abbd2 100644 --- a/docs/engineering/skills/type-checking.md +++ b/docs/engineering/skills/type-checking.md @@ -11,9 +11,9 @@ Every uv package is type-checked with Pyright on every pull request, by the - `sim.quality.pyright-libraries-and-entry`, `sim.quality.pyright-gateway`, `sim.quality.pyright-executor` and `sim.quality.pyright-apis-integ`: the - contract, observability, Stage 12 persistence and fastapi libraries, the - simulation entry, the gateway, the executor and the generated-client - integration tests have no Pyright errors. + contract, observability, simulation persistence, Stage 12 persistence and + fastapi libraries, the simulation entry, the gateway, the executor and the + generated-client integration tests have no Pyright errors. - `sim.quality.every-package-type-checked`: a Pyright rule lists every uv package under `libs/` and `projects/`. When you add a package, give it a `[tool.pyright] include`, add it to a Pyright rule's `enforcer.projects`, and diff --git a/docs/generated/rules.md b/docs/generated/rules.md index b2f783267..5060ca96e 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -310,7 +310,7 @@ flowchart LR | `sim.quality.pyright-apis-integ` | The generated-client integration-test project, apis-integ, has no Pyright errors, checked with the pinned Pyright version in the project's own locked environment. | mechanical | enforced | yes | pyright in `projects/policyengine-apis-integ` | `pr.yml:pyright` | yes | | `sim.quality.pyright-executor` | The simulation executor has no Pyright errors, checked with the pinned Pyright version in the executor's own locked environment. | mechanical | enforced | yes | pyright in `projects/policyengine-simulation-executor` | `pr.yml:pyright` | yes | | `sim.quality.pyright-gateway` | The simulation gateway has no Pyright errors, checked with the pinned Pyright version in the gateway's own locked environment. | mechanical | enforced | yes | pyright in `projects/policyengine-simulation-gateway` | `pr.yml:pyright` | yes | -| `sim.quality.pyright-libraries-and-entry` | The contract, observability, Stage 12 persistence and fastapi libraries and the simulation entry have no Pyright errors, checked with the pinned Pyright version in each package's own locked environment. | mechanical | enforced | yes | pyright in `libs/policyengine-simulation-contract`, `libs/policyengine-simulation-observability`, `libs/policyengine-stage12-persistence`, `libs/policyengine-fastapi`, `projects/policyengine-simulation-entry` | `pr.yml:pyright` | yes | +| `sim.quality.pyright-libraries-and-entry` | The contract, observability, simulation persistence, Stage 12 persistence and fastapi libraries and the simulation entry have no Pyright errors, checked with the pinned Pyright version in each package's own locked environment. | mechanical | enforced | yes | pyright in `libs/policyengine-simulation-contract`, `libs/policyengine-simulation-observability`, `libs/policyengine-simulation-persistence`, `libs/policyengine-stage12-persistence`, `libs/policyengine-fastapi`, `projects/policyengine-simulation-entry` | `pr.yml:pyright` | yes | | `sim.quality.python-file-length` | No tracked Python file has more than 800 lines, or more than 1,500 lines if it is test code: a file under a tests or fixtures directory, or a conftest.py. | metric | enforced | yes | quality guard `file-length` | `pr.yml:quality-guards` | yes | | `sim.quality.ruff-format-src` | Source code under projects/*/src and libs/*/src is formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — | | `sim.quality.ruff-format-tests` | Test code is formatted with ruff format too; CI currently checks only src directories. | mechanical | doc-only | no | nothing | — | — | @@ -343,7 +343,7 @@ flowchart LR r_sim_quality_pyright_gateway("sim.quality.pyright-gateway (enforced)") e_pyright_in_projects_policyengine_simulation_gateway["pyright in projects/policyengine-simulation-gateway"] r_sim_quality_pyright_libraries_and_entry("sim.quality.pyright-libraries-and-entry (enforced)") - e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry["pyright in libs/policyengine-simulation-contract, libs/policyengine-simulation-observability, libs/policyengine-stage12-persistence, libs/policyengine-fastapi, projects/policyengine-simulation-entry"] + e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_simulation_persistence__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry["pyright in libs/policyengine-simulation-contract, libs/policyengine-simulation-observability, libs/policyengine-simulation-persistence, libs/policyengine-stage12-persistence, libs/policyengine-fastapi, projects/policyengine-simulation-entry"] r_sim_quality_python_file_length("sim.quality.python-file-length (enforced)") e_quality_guard_file_length["quality guard file-length"] r_sim_quality_ruff_format_src("sim.quality.ruff-format-src (enforced)") @@ -377,8 +377,8 @@ flowchart LR e_pyright_in_projects_policyengine_simulation_executor --> j_CI_pr_yml_pyright r_sim_quality_pyright_gateway --> e_pyright_in_projects_policyengine_simulation_gateway e_pyright_in_projects_policyengine_simulation_gateway --> j_CI_pr_yml_pyright - r_sim_quality_pyright_libraries_and_entry --> e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry - e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry --> j_CI_pr_yml_pyright + r_sim_quality_pyright_libraries_and_entry --> e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_simulation_persistence__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry + e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_simulation_persistence__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry --> j_CI_pr_yml_pyright r_sim_quality_python_file_length --> e_quality_guard_file_length e_quality_guard_file_length --> j_CI_pr_yml_quality_guards r_sim_quality_ruff_format_src --> e_ruff_format diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md new file mode 100644 index 000000000..9e6e35a49 --- /dev/null +++ b/libs/policyengine-simulation-persistence/README.md @@ -0,0 +1,28 @@ +# PolicyEngine simulation persistence + +SQLAlchemy Core tables and database connections for the Simulation +Entrypoint's own Postgres schema, `simulation`. The Simulation Entrypoint owns +every table in that schema; no other service creates, changes or writes them. + +The temporary Stage 12 comparison tables are not part of this schema: they are +owned and migrated by the Public API, and `libs/policyengine-stage12-persistence` +writes them with DML only. + +## Connections + +`create_simulation_engine` builds the runtime engine from the canonical +`postgresql://` URL the deployment stores, selecting the psycopg 3 driver. It +keeps a small pool, checks connections before use, and never prepares +server-side statements, so the same URL works through Supabase's session and +transaction poolers. A malformed URL raises `ValueError` without repeating the +URL, which holds a password. + +`read_schema_revision` returns the migration revision the database has applied, +or `None` before the first migration. + +## Tests + +```bash +uv sync --extra test +uv run pytest tests/ +``` diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml new file mode 100644 index 000000000..96ddf7231 --- /dev/null +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "policyengine-simulation-persistence" +version = "0.1.0" +readme = "README.md" +authors = [ + {name = "PolicyEngine", email = "hello@policyengine.org"}, +] +license = {file = "../../LICENSE"} +requires-python = ">=3.13,<3.14" +dependencies = [ + "psycopg[binary]>=3.2,<4", + "sqlalchemy>=2,<3", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.3.4", + "pytest-cov>=6.1.1", +] +build = [ + "pyright>=1.1.401", +] + +[build-system] +requires = ["hatchling>=1.26"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +sources = ["src"] +only-include = ["src/policyengine_simulation_persistence"] +packages = ["src/policyengine_simulation_persistence"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py new file mode 100644 index 000000000..a4a461367 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -0,0 +1,23 @@ +"""Tables and connections for the Simulation Entrypoint's ``simulation`` schema.""" + +from policyengine_simulation_persistence.engine import ( + create_simulation_engine, + normalized_database_url, + read_schema_revision, +) +from policyengine_simulation_persistence.schema import ( + ALEMBIC_VERSION_TABLE, + RUNTIME_ROLE, + SIMULATION_SCHEMA, + simulation_metadata, +) + +__all__ = [ + "ALEMBIC_VERSION_TABLE", + "RUNTIME_ROLE", + "SIMULATION_SCHEMA", + "create_simulation_engine", + "normalized_database_url", + "read_schema_revision", + "simulation_metadata", +] diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/engine.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/engine.py new file mode 100644 index 000000000..58e70b3dc --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/engine.py @@ -0,0 +1,76 @@ +"""Database connections for the Simulation Entrypoint's own schema.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import create_engine, inspect, select +from sqlalchemy.engine import URL, make_url +from sqlalchemy.exc import ArgumentError + +from policyengine_simulation_persistence.schema import ( + ALEMBIC_VERSION_TABLE, + SIMULATION_SCHEMA, + alembic_version, +) + +if TYPE_CHECKING: + from sqlalchemy import Connection, Engine + +CONNECT_TIMEOUT_SECONDS = 5 +# Each Cloud Run instance serves up to 80 concurrent requests. Each database +# operation holds a connection for one short transaction, so a small pool is +# enough. +POOL_SIZE = 2 +MAX_OVERFLOW = 3 + + +def normalized_database_url(database_url: str) -> URL: + """Return ``database_url`` with the psycopg 3 driver selected. + + The stored secrets use the canonical ``postgresql://`` scheme. Raise + ValueError, without echoing the value, when the URL is missing, malformed + or incomplete. + """ + if not database_url or database_url != database_url.strip(): + msg = "Simulation database URL is missing or malformed" + raise ValueError(msg) + try: + parsed = make_url(database_url) + except (ArgumentError, ValueError): + # Raised without the parser's message, which may quote the URL. + msg = "Simulation database URL is malformed" + raise ValueError(msg) from None + if parsed.drivername != "postgresql": + msg = "Simulation database URL must use postgresql://" + raise ValueError(msg) + if not parsed.username or not parsed.host or not parsed.database: + msg = "Simulation database URL is incomplete" + raise ValueError(msg) + return parsed.set(drivername="postgresql+psycopg") + + +def create_simulation_engine(database_url: str) -> Engine: + """Build the engine the Simulation Entrypoint uses; it creates no objects.""" + return create_engine( + normalized_database_url(database_url), + pool_size=POOL_SIZE, + max_overflow=MAX_OVERFLOW, + pool_pre_ping=True, + connect_args={ + "connect_timeout": CONNECT_TIMEOUT_SECONDS, + # Never use server-side prepared statements, so the same engine + # works through Supabase's session and transaction poolers. + "prepare_threshold": None, + }, + ) + + +def read_schema_revision(connection: Connection) -> str | None: + """Return the applied Alembic revision, or None before the first migration.""" + if not inspect(connection).has_table( + ALEMBIC_VERSION_TABLE, + schema=SIMULATION_SCHEMA, + ): + return None + return connection.execute(select(alembic_version.c.version_num)).scalar() diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py new file mode 100644 index 000000000..3ff187dd0 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -0,0 +1,40 @@ +"""Names and metadata of the Simulation Entrypoint's own Postgres schema. + +The Simulation Entrypoint owns every table in the ``simulation`` schema. One +Alembic chain, at the repository's ``migrations/`` directory, creates and +changes them, and records its revision in ``simulation.alembic_version``. +""" + +from __future__ import annotations + +from sqlalchemy import Column, MetaData, String, Table + +SIMULATION_SCHEMA = "simulation" +ALEMBIC_VERSION_TABLE = "alembic_version" +# The login role the Simulation Entrypoint runtime connects as. Migrations grant +# it schema usage and table DML; it never owns or changes schema objects. +RUNTIME_ROLE = "simulation_runtime" + +NAMING_CONVENTION = { + "ix": "ix_%(table_name)s_%(column_0_N_name)s", + "uq": "uq_%(table_name)s_%(column_0_N_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +# Every table the Simulation Entrypoint owns is registered here. +simulation_metadata = MetaData( + schema=SIMULATION_SCHEMA, + naming_convention=NAMING_CONVENTION, +) + +# Alembic creates and owns this table; it is described here, outside +# simulation_metadata, only so the runtime can read the applied revision +# without depending on Alembic. +_alembic_metadata = MetaData(schema=SIMULATION_SCHEMA) +alembic_version = Table( + ALEMBIC_VERSION_TABLE, + _alembic_metadata, + Column("version_num", String(32), primary_key=True), +) diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py new file mode 100644 index 000000000..f6e40a4c1 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -0,0 +1,77 @@ +"""Schema names and connection settings, without a database.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_persistence import ( + RUNTIME_ROLE, + SIMULATION_SCHEMA, + create_simulation_engine, + normalized_database_url, + simulation_metadata, +) + + +def test_the_schema_and_runtime_role_have_their_deployed_names() -> None: + assert SIMULATION_SCHEMA == "simulation" + assert RUNTIME_ROLE == "simulation_runtime" + + +def test_every_table_is_in_the_simulation_schema() -> None: + assert simulation_metadata.schema == SIMULATION_SCHEMA + for table in simulation_metadata.tables.values(): + assert table.schema == SIMULATION_SCHEMA, table.name + + +def test_database_url_selects_psycopg_without_changing_the_secret_format() -> None: + url = normalized_database_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + + assert url.drivername == "postgresql+psycopg" + assert url.username == "simulation_runtime" + assert url.host == "db.example" + assert url.database == "postgres" + + +@pytest.mark.parametrize( + "database_url", + [ + "", + " postgresql://user:secret@db.example/postgres", + "postgres://user:secret@db.example/postgres", + "postgresql+psycopg://user:secret@db.example/postgres", + "postgresql://db.example/postgres", + "postgresql://user:secret@db.example", + "not a url", + ], +) +def test_malformed_database_urls_are_rejected_without_echoing_them( + database_url, +) -> None: + with pytest.raises(ValueError, match="Simulation database URL") as error: + normalized_database_url(database_url) + + assert "secret" not in str(error.value) + assert error.value.__cause__ is None or "secret" not in str(error.value.__cause__) + + +def test_runtime_engine_never_prepares_server_side_statements(monkeypatch) -> None: + captured_options = {} + + def capture_engine(url, **options): + captured_options.update(options, url=url) + return object() + + monkeypatch.setattr( + "policyengine_simulation_persistence.engine.create_engine", + capture_engine, + ) + + create_simulation_engine("postgresql://runtime:secret@db.example/postgres") + + assert captured_options["url"].drivername == "postgresql+psycopg" + assert captured_options["connect_args"]["prepare_threshold"] is None + assert captured_options["connect_args"]["connect_timeout"] > 0 + assert captured_options["pool_pre_ping"] is True + assert captured_options["pool_size"] >= 1 diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock new file mode 100644 index 000000000..e49da7694 --- /dev/null +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -0,0 +1,227 @@ +version = 1 +revision = 5 +requires-python = "==3.13.*" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "policyengine-simulation-persistence" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "psycopg", extra = ["binary"] }, + { name = "sqlalchemy" }, +] + +[package.optional-dependencies] +build = [ + { name = "pyright" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, + { name = "sqlalchemy", specifier = ">=2,<3" }, +] +provides-extras = ["test", "build"] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/dc/9addbf490110b5727380059ee542ad8353bdcee45a37ba6a5e0976966ae0/sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52", size = 10589700, upload-time = "2026-10-21T16:25:06.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/db/ecf1cf1f5c8cb7661081dcb0561633c174213d7af5fccc17ab48cbe9812a/sqlalchemy-2.1.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0534d3f41295a74d9fe2e0838e06ce6329a0855b2965df5f5d1c4d3f6f1406d0", size = 2468626, upload-time = "2026-10-21T18:49:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/9b/98/1cccefb4fc94f4efd621604063acbbea4e3d530c27151fd7249d68adabf2/sqlalchemy-2.1.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:650f97996a4c529f4930969f3ba11bcdb57ff160ed1cb87ea0174393bf4f5f73", size = 4602545, upload-time = "2026-10-21T18:54:18.766Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/1817d7406ed92c8647a1d1db965a1bc2dd5a3f80d18e4d28ad62daf9bd72/sqlalchemy-2.1.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bada6bd29ae58a0581bf6977c0a43dfd67388fd09129f1475c37eca370cccd8", size = 4655491, upload-time = "2026-10-21T19:05:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/41/5c/0bdaa1676fb182b93e3c87c09cafd08a734ef29820aac6312b87a88f8537/sqlalchemy-2.1.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96f5fcdfb7e89b6d895e2ddc165f67b3126a585dfdf2a95b0c1f3fcf3380d7d4", size = 4319667, upload-time = "2026-10-21T19:23:16.637Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d7/319e5a049b6f5235d1229858972abb05b8eae2fdd6639dc7bdbbc9cdb68d/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:015bd7b0687b5feaecb9988bc2f36ecb39daca83bd806b094b43b19ea503b0a9", size = 4525975, upload-time = "2026-10-21T18:54:21.553Z" }, + { url = "https://files.pythonhosted.org/packages/19/3b/6e8e5c14a3bcfae59a081a27f33e4f225df8140daab8e90fc3c42afd0d0e/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bf883ae772971bc6155cb64cdfb9e31c50a2099f3b7c8b51b0401bf608a9fe8e", size = 4320900, upload-time = "2026-10-21T19:23:18.835Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a7/7ad220d5ea6308a9a32395785f4ce8beff4d00b0ae56ab9f2dafcca28743/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ff897d13de613768123dee68474956a51fe641c42bba7af6cb7776716a8dbcb", size = 4616756, upload-time = "2026-10-21T19:05:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/08/81/53a198f5a29f247adc68c0c6da69bd2c7bdd89d3ffc58f9e626ee3160f18/sqlalchemy-2.1.6-cp313-cp313-win32.whl", hash = "sha256:9db8bacb2a69b4b5388bc1439463170af28f4cc710933085b81d1658d931d2c2", size = 2382042, upload-time = "2026-10-21T17:11:33.968Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/69e86a23cd9ccef0d654c09dc69dfea6d62b1311d2f13ca3daab1bcd922e/sqlalchemy-2.1.6-cp313-cp313-win_amd64.whl", hash = "sha256:b9f0b92b4a88b630f1e67df816b43cb017bb583479917dd100541d4f490a9517", size = 2432597, upload-time = "2026-10-21T17:11:35.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/90/a7f81395135cc5da724403ece4340f7e04c1ce752da15d0ad337591ec9c9/sqlalchemy-2.1.6-cp313-cp313-win_arm64.whl", hash = "sha256:bc495d00f235c3df6f376187a4ef48a56e162fdcc0a113ee34518332aa1aa2df", size = 2393224, upload-time = "2026-10-21T18:54:18.405Z" }, + { url = "https://files.pythonhosted.org/packages/c1/69/c75534e336b06b3fd3f76de73d89736510800e60c77c58d4e4ce18389018/sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe", size = 2063055, upload-time = "2026-10-21T18:42:10.234Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] diff --git a/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py b/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py index 81d7ad590..18293a918 100644 --- a/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py +++ b/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py @@ -34,6 +34,7 @@ "libs/policyengine-fastapi/src", "libs/policyengine-simulation-contract/src", "libs/policyengine-simulation-observability/src", + "libs/policyengine-simulation-persistence/src", "libs/policyengine-stage12-persistence/src", ".github/scripts", ) diff --git a/rules/registry.toml b/rules/registry.toml index f298eb988..4fe7272bb 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -1067,11 +1067,12 @@ invocation = "unittest discover -s scripts/rules/tests" [[rule]] id = "sim.quality.pyright-libraries-and-entry" category = "quality" -statement = "The contract, observability, Stage 12 persistence and fastapi libraries and the simulation entry have no Pyright errors, checked with the pinned Pyright version in each package's own locked environment." +statement = "The contract, observability, simulation persistence, Stage 12 persistence and fastapi libraries and the simulation entry have no Pyright errors, checked with the pinned Pyright version in each package's own locked environment." rationale = "docs/engineering/skills/type-checking.md#rules" scope = [ "libs/policyengine-simulation-contract/src/**/*.py", "libs/policyengine-simulation-observability/src/**/*.py", + "libs/policyengine-simulation-persistence/src/**/*.py", "libs/policyengine-stage12-persistence/src/**/*.py", "libs/policyengine-fastapi/src/**/*.py", "projects/policyengine-simulation-entry/src/**/*.py", @@ -1084,6 +1085,7 @@ kind = "pyright" projects = [ "libs/policyengine-simulation-contract", "libs/policyengine-simulation-observability", + "libs/policyengine-simulation-persistence", "libs/policyengine-stage12-persistence", "libs/policyengine-fastapi", "projects/policyengine-simulation-entry", From 1ea2627f43262bd1950f43cc2aa8080fed08edce Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:12:40 +0400 Subject: [PATCH 025/124] Add the simulation schema's Alembic chain with its ownership rules Replace sim.stage12.no-migration-chain, which forbade any Alembic chain in this repository, with ownership rules for the Simulation Entrypoint's own "simulation" schema: - sim.persistence.one-migration-chain: one Alembic chain at migrations/, configured by migrations/alembic.ini, keeps its version table at simulation.alembic_version, and no other Alembic configuration, environment or revision directory exists; - sim.persistence.executor-independent: the Modal executor and gateway never declare, lock or import the persistence library; - sim.persistence.no-modal-database-credentials: no Modal secret or deployment receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL, so the comparison path's own database secret stays the only database credential Modal holds; - sim.persistence.migration-cycle: on a fresh PostgreSQL, the chain upgrades, passes alembic check, downgrades and upgrades again as a non-superuser migrator, and the runtime role reads the revision but cannot change the schema. The environment creates the schema only when it is missing, because PostgreSQL checks CREATE on the database even for CREATE SCHEMA IF NOT EXISTS; an operator may therefore create the schema in advance, owned by the migrator. The schema creation and every pending revision run in one transaction, so a failed migration leaves nothing applied. Revision 0001 grants the runtime role schema usage, revision reads and default DML privileges on the tables later revisions create. Migrations run only online and only with SIMULATION_MIGRATION_DATABASE_URL. A new "Simulation database" PR job runs the database tests on postgres:17 and is part of "CI complete". The database access guard now also scans the chain, which may import alembic and change the schema but still connects only through SQLAlchemy. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yml | 50 +++- AGENTS.md | 6 +- docs/engineering/skills/database-access.md | 27 +-- docs/generated/rules.md | 40 +++- .../README.md | 66 +++++- .../pyproject.toml | 9 + .../__init__.py | 2 + .../schema.py | 4 + .../tests/conftest.py | 116 ++++++++++ .../tests/migration_support.py | 18 ++ .../tests/ownership_checks.py | 216 ++++++++++++++++++ .../tests/test_migration_cycle.py | 166 ++++++++++++++ .../tests/test_ownership_boundaries.py | 89 ++++++++ .../tests/test_schema.py | 29 +++ .../uv.lock | 82 ++++++- migrations/alembic.ini | 44 ++++ migrations/env.py | 108 +++++++++ migrations/script.py.mako | 26 +++ .../0001_initial_simulation_schema.py | 52 +++++ .../tests/test_database_access_boundaries.py | 6 +- .../tests/test_stage12_schema_authority.py | 10 +- ruff.toml | 4 +- .../bad_pyproject.toml | 7 + .../good_pyproject.toml | 6 + .../bad_workflow.yml | 11 + .../good_workflow.yml | 16 ++ .../bad_repository/migrations/alembic.ini | 2 + .../bad_repository/migrations/env.py | 7 + .../migrations/versions/0001_initial.py | 4 + .../projects/service/alembic.ini | 2 + .../migrations/versions/0001_service.py | 4 + .../good_repository/migrations/alembic.ini | 2 + .../good_repository/migrations/env.py | 7 + .../migrations/versions/0001_initial.py | 4 + rules/registry.toml | 113 +++++++-- 35 files changed, 1303 insertions(+), 52 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/tests/conftest.py create mode 100644 libs/policyengine-simulation-persistence/tests/migration_support.py create mode 100644 libs/policyengine-simulation-persistence/tests/ownership_checks.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_migration_cycle.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0001_initial_simulation_schema.py create mode 100644 rules/fixtures/sim.persistence.executor-independent/bad_pyproject.toml create mode 100644 rules/fixtures/sim.persistence.executor-independent/good_pyproject.toml create mode 100644 rules/fixtures/sim.persistence.no-modal-database-credentials/bad_workflow.yml create mode 100644 rules/fixtures/sim.persistence.no-modal-database-credentials/good_workflow.yml create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/alembic.ini create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/env.py create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/versions/0001_initial.py create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/alembic.ini create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/migrations/versions/0001_service.py create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/alembic.ini create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/env.py create mode 100644 rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/versions/0001_initial.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 805305f71..ac46f768b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: # every job passed. See docs/engineering/skills/github-prs.md. name: CI complete if: ${{ always() }} - needs: [test, lint, pyright, quality-guards, docker-build, test-integration] + needs: [test, simulation-database, lint, pyright, quality-guards, docker-build, test-integration] runs-on: ubuntu-latest permissions: contents: read @@ -76,6 +76,54 @@ jobs: working-directory: ${{ matrix.project }} run: uv run --frozen pytest tests/ -v + simulation-database: + name: Simulation database + # Runs the simulation schema's migration chain (upgrade, alembic check, + # downgrade, upgrade) and the persistence tests that need a database, on a + # fresh PostgreSQL. Full CI runs only on the lowest unmerged pull request + # in a stack. + if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }} + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: simulation_ci + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + env: + SIMULATION_TEST_DATABASE_URL: postgresql://postgres:simulation_ci@127.0.0.1:5432/postgres + # A missing server fails the tests instead of skipping them. + SIMULATION_REQUIRE_DATABASE_TESTS: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install dependencies + working-directory: libs/policyengine-simulation-persistence + run: uv sync --extra test --frozen + + - name: Run the migration chain and persistence tests on PostgreSQL + working-directory: libs/policyengine-simulation-persistence + run: uv run --frozen pytest tests/ -v -m postgres + lint: name: Lint # Full CI runs only on the lowest unmerged pull request in a stack. diff --git a/AGENTS.md b/AGENTS.md index 12ba40fb5..4c15161c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,11 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Stage 12 persistence -- Checked: `sim.stage12.deployment-scope`, `sim.stage12.deployment-uses-api-schema`, `sim.stage12.no-migration-chain`, `sim.stage12.no-production-user-associations`, `sim.stage12.no-schema-ddl`, `sim.stage12.persistence-typed-dml-only`, `sim.stage12.production-lock-has-db-runtime` +- Checked: `sim.stage12.deployment-scope`, `sim.stage12.deployment-uses-api-schema`, `sim.stage12.no-production-user-associations`, `sim.stage12.no-schema-ddl`, `sim.stage12.persistence-typed-dml-only`, `sim.stage12.production-lock-has-db-runtime` + +### Simulation persistence + +- Checked: `sim.persistence.executor-independent`, `sim.persistence.migration-cycle`, `sim.persistence.no-modal-database-credentials`, `sim.persistence.one-migration-chain` ### Deployment pipeline diff --git a/docs/engineering/skills/database-access.md b/docs/engineering/skills/database-access.md index 1ed83a886..8448cb941 100644 --- a/docs/engineering/skills/database-access.md +++ b/docs/engineering/skills/database-access.md @@ -21,31 +21,32 @@ It builds on the Stage 12 rules: - `sim.stage12.no-schema-ddl`: the simulation runtimes and the Stage 12 persistence library contain no schema DDL. Its `DDL_PATTERN` regex is the one this rule matches against every source directory's string literals. -- `sim.stage12.no-migration-chain`: this repository has no migration chain - of its own; policyengine-api migrates the Stage 12 tables. +- `sim.persistence.one-migration-chain`: the repository's one Alembic chain, + at `migrations/`, owns the Simulation Entrypoint's `simulation` schema; + the Public API migrates the Stage 12 tables. - `sim.stage12.persistence-typed-dml-only` and `sim.stage12.deployment-uses-api-schema`: the Stage 12 persistence library uses typed SQLAlchemy Core DML only, with no raw SQL text. ## Who owns the schema -- **Today**, policyengine-api's Alembic migrations own the only tables this - repository uses, the Stage 12 comparison tables - (`sim.stage12.no-migration-chain`). This repository's code reads and - writes their rows, and deployment validation compares their live schema - with its SQLAlchemy mappings; nothing here changes them. -- **Once #847 merges**, this repository's own Alembic chain at `migrations/` - owns the `simulation` schema, and the Stage 12 tables stay with - policyengine-api. +- **The `simulation` schema** belongs to this repository's own Alembic chain + at `migrations/` (`sim.persistence.one-migration-chain`). The tables are + declared in `libs/policyengine-simulation-persistence`, whose README + describes the chain, the database roles and how to add a revision. +- **The Stage 12 comparison tables** belong to the Public API's Alembic + migrations. This repository's code reads and writes their rows, and + deployment validation compares their live schema with its SQLAlchemy + mappings; nothing here changes them. -The rule holds either way: whichever chain owns a table, only that chain's -migrations change it. +Either way, only the chain that owns a table changes it. ## What the guard checks `projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py` parses every Python file under `projects/*/src` (including the executor's -`src/modal`), `libs/*/src` and `.github/scripts`, and runs in the entry +`src/modal`), `libs/*/src`, `.github/scripts` and the repository's +`migrations/` chain, and runs in the entry project's `Test` job. The checks live in `database_access_support.py` next to it. It fails on: diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 5060ca96e..dba75bacf 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 53 | 2 | 0 | 14 | 69 | +| mechanical | 56 | 2 | 0 | 14 | 72 | | metric | 1 | 0 | 0 | 0 | 1 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 56 of 70 rules. +**Blocking:** 59 of 73 rules. ## Gaps @@ -54,10 +54,10 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.deploy.smoke-tests-strict` - `sim.deploy.traffic-changes-exact` - `sim.deploy.workflow-shell-safety` +- `sim.persistence.migration-cycle` - `sim.process.rules-registry` - `sim.stage12.deployment-scope` - `sim.stage12.deployment-uses-api-schema` -- `sim.stage12.no-migration-chain` - `sim.stage12.no-production-user-associations` - `sim.stage12.persistence-typed-dml-only` - `sim.stage12.production-lock-has-db-runtime` @@ -103,7 +103,6 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | --- | | `sim.stage12.deployment-scope` | Main-branch deploys roll out Stage 12 in both environments; a Stage 12-only deploy runs candidate tests and the requested promotion but never redeploys existing Modal resources or runs the existing stack's complete integration suite; the Stage 12 worker can request a Google OIDC token. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py` | `pr.yml:test` | **unproven** | | `sim.stage12.deployment-uses-api-schema` | Stage 12 deployment uses the API-owned schema directly, and the Stage 12 runtime it deploys uses typed DML without raw SQL or schema changes. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | -| `sim.stage12.no-migration-chain` | This repository has no schema migration chain (no Alembic); Stage 12 tables are migrated by policyengine-api. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py` | `pr.yml:test` | **unproven** | | `sim.stage12.no-production-user-associations` | Stage 12 runtime modules never create production user associations (user_simulations or user_reports). | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py` | `pr.yml:test` | **unproven** | | `sim.stage12.no-schema-ddl` | Simulation runtimes and the Stage 12 persistence library contain no schema DDL (CREATE, ALTER, DROP or TRUNCATE of tables, types, schemas or databases, or create_all); policyengine-api owns the schema. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py` | `pr.yml:test` | yes | | `sim.stage12.persistence-typed-dml-only` | The Stage 12 persistence library and runtime use typed SQLAlchemy Core DML only: no raw SQL text, driver-level SQL or schema operations. | mechanical | enforced | yes | pytest `libs/policyengine-stage12-persistence/tests/test_source_boundaries.py` | `pr.yml:test` | **unproven** | @@ -116,9 +115,8 @@ flowchart LR j_CI_pr_yml_test["CI pr.yml:test"] r_sim_stage12_deployment_uses_api_schema("sim.stage12.deployment-uses-api-schema (enforced)") e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets.py"] - r_sim_stage12_no_migration_chain("sim.stage12.no-migration-chain (enforced)") - e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py["pytest projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py"] r_sim_stage12_no_production_user_associations("sim.stage12.no-production-user-associations (enforced)") + e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py["pytest projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py"] r_sim_stage12_no_schema_ddl("sim.stage12.no-schema-ddl (enforced)") r_sim_stage12_persistence_typed_dml_only("sim.stage12.persistence-typed-dml-only (enforced)") e_pytest_libs_policyengine_stage12_persistence_tests_test_source_boundaries_py["pytest libs/policyengine-stage12-persistence/tests/test_source_boundaries.py"] @@ -128,9 +126,8 @@ flowchart LR e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_promotion_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py --> j_CI_pr_yml_test r_sim_stage12_deployment_uses_api_schema --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py --> j_CI_pr_yml_test - r_sim_stage12_no_migration_chain --> e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py - e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py --> j_CI_pr_yml_test r_sim_stage12_no_production_user_associations --> e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py + e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py --> j_CI_pr_yml_test r_sim_stage12_no_schema_ddl --> e_pytest_projects_policyengine_simulation_entry_tests_test_stage12_schema_authority_py r_sim_stage12_persistence_typed_dml_only --> e_pytest_libs_policyengine_stage12_persistence_tests_test_source_boundaries_py e_pytest_libs_policyengine_stage12_persistence_tests_test_source_boundaries_py --> j_CI_pr_yml_test @@ -138,6 +135,33 @@ flowchart LR e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_traffic_py --> j_CI_pr_yml_test ``` +## Simulation persistence + +| Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `sim.persistence.executor-independent` | The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | +| `sim.persistence.migration-cycle` | On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py` | `pr.yml:simulation-database` | **unproven** | +| `sim.persistence.no-modal-database-credentials` | No Modal secret, Modal deployment or Modal application receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL; until the comparison path is removed, its own database secret is the only database credential Modal holds. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | +| `sim.persistence.one-migration-chain` | The simulation schema changes only through the one Alembic chain at migrations/, configured by migrations/alembic.ini and versioned in simulation.alembic_version; no other Alembic configuration, environment or revision directory exists. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | + +```mermaid +flowchart LR + r_sim_persistence_executor_independent("sim.persistence.executor-independent (enforced)") + e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py["pytest libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py"] + j_CI_pr_yml_test["CI pr.yml:test"] + r_sim_persistence_migration_cycle("sim.persistence.migration-cycle (enforced)") + e_pytest_libs_policyengine_simulation_persistence_tests_test_migration_cycle_py["pytest libs/policyengine-simulation-persistence/tests/test_migration_cycle.py"] + j_CI_pr_yml_simulation_database["CI pr.yml:simulation-database"] + r_sim_persistence_no_modal_database_credentials("sim.persistence.no-modal-database-credentials (enforced)") + r_sim_persistence_one_migration_chain("sim.persistence.one-migration-chain (enforced)") + r_sim_persistence_executor_independent --> e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py + e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py --> j_CI_pr_yml_test + r_sim_persistence_migration_cycle --> e_pytest_libs_policyengine_simulation_persistence_tests_test_migration_cycle_py + e_pytest_libs_policyengine_simulation_persistence_tests_test_migration_cycle_py --> j_CI_pr_yml_simulation_database + r_sim_persistence_no_modal_database_credentials --> e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py + r_sim_persistence_one_migration_chain --> e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py +``` + ## Deployment pipeline | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 9e6e35a49..35fdcb0a9 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -8,6 +8,44 @@ The temporary Stage 12 comparison tables are not part of this schema: they are owned and migrated by the Public API, and `libs/policyengine-stage12-persistence` writes them with DML only. +## Ownership rules + +These rules are registered in `rules/registry.toml` and checked by +`tests/test_ownership_boundaries.py`: + +- **One migration chain** (`sim.persistence.one-migration-chain`). Schema + changes are made only by the Alembic chain in the repository's + `migrations/` directory, configured by `migrations/alembic.ini`. It keeps its + version table at `simulation.alembic_version`. No other Alembic + configuration, environment or revision directory exists in the repository. + Runtime code never changes a schema + (`sim.boundaries.sqlalchemy-and-alembic-only`). +- **The executor does not depend on persistence** + (`sim.persistence.executor-independent`). The Modal executor and gateway + never declare, lock or import this library. Only the Simulation Entrypoint + and the migration job install it. +- **No database credentials in Modal** + (`sim.persistence.no-modal-database-credentials`). No Modal secret or Modal + deployment receives `SIMULATION_DATABASE_URL` or + `SIMULATION_MIGRATION_DATABASE_URL`. The Stage 12 comparison's own database + secret is the only database credential Modal holds, until the comparison + path is removed. + +## Database roles + +| Role | Used by | Privileges | +| --- | --- | --- | +| `simulation_migrator` | The deploy workflow's migration job, through `SIMULATION_MIGRATION_DATABASE_URL` | Creates the `simulation` schema if it is missing, and owns every object in it | +| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create | + +The initial revision grants the runtime role its privileges, including default +privileges on tables the migrator creates later. Both roles must exist before +the first migration runs; an operator creates them. The migrator needs either +`CREATE` on the database or a `simulation` schema created in advance and owned +by it: the environment creates the schema only when it is missing, because +PostgreSQL checks `CREATE` on the database even for +`CREATE SCHEMA IF NOT EXISTS`. + ## Connections `create_simulation_engine` builds the runtime engine from the canonical @@ -18,7 +56,26 @@ transaction poolers. A malformed URL raises `ValueError` without repeating the URL, which holds a password. `read_schema_revision` returns the migration revision the database has applied, -or `None` before the first migration. +or `None` before the first migration. `HEAD_REVISION` is the newest revision +this code knows. + +## Adding a revision + +1. Change the tables in `src/policyengine_simulation_persistence/`. +2. Add a revision under `migrations/versions/` whose `down_revision` is the + current head, and set `HEAD_REVISION` in `schema.py` to its revision ID. +3. Run the tests against a disposable PostgreSQL server (below). They run the + chain's upgrade, `alembic check`, downgrade and upgrade cycle. + +The schema creation and every pending revision apply in one transaction, so a +failed migration leaves nothing applied. The previous release keeps serving +traffic while a deployment migrates, and a rollback moves traffic back to an +older image without downgrading the schema, so every revision must work with +the code of the release before it: add columns and tables, and remove them +only in a later release once nothing reads them. + +Only one migration PR in this chain may merge at a time; another one open at +the same time rebases onto the new head after the first merges. ## Tests @@ -26,3 +83,10 @@ or `None` before the first migration. uv sync --extra test uv run pytest tests/ ``` + +Tests marked `postgres` need a disposable PostgreSQL server. Point +`SIMULATION_TEST_DATABASE_URL` at its superuser, for example +`postgresql://postgres:password@127.0.0.1:5432/postgres`; each test creates and +drops its own database. Without the variable those tests skip. CI's +`Simulation database` job runs them against `postgres:17` with +`SIMULATION_REQUIRE_DATABASE_TESTS=1`, which turns a skip into a failure. diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml index 96ddf7231..4dab6a1a1 100644 --- a/libs/policyengine-simulation-persistence/pyproject.toml +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -13,9 +13,15 @@ dependencies = [ ] [project.optional-dependencies] +# The migration job installs Alembic; the Simulation Entrypoint runtime does not. +migrations = [ + "alembic>=1.16,<2", +] test = [ + "alembic>=1.16,<2", "pytest>=8.3.4", "pytest-cov>=6.1.1", + "pyyaml>=6.0.2", ] build = [ "pyright>=1.1.401", @@ -33,6 +39,9 @@ packages = ["src/policyengine_simulation_persistence"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] +markers = [ + "postgres: needs a disposable PostgreSQL server named by SIMULATION_TEST_DATABASE_URL", +] [tool.pyright] # The pr.yml pyright job checks these paths: python -m scripts.rules pyright. diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index a4a461367..fb098c8ed 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -7,6 +7,7 @@ ) from policyengine_simulation_persistence.schema import ( ALEMBIC_VERSION_TABLE, + HEAD_REVISION, RUNTIME_ROLE, SIMULATION_SCHEMA, simulation_metadata, @@ -14,6 +15,7 @@ __all__ = [ "ALEMBIC_VERSION_TABLE", + "HEAD_REVISION", "RUNTIME_ROLE", "SIMULATION_SCHEMA", "create_simulation_engine", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py index 3ff187dd0..a7cd53c18 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -14,6 +14,10 @@ # The login role the Simulation Entrypoint runtime connects as. Migrations grant # it schema usage and table DML; it never owns or changes schema objects. RUNTIME_ROLE = "simulation_runtime" +# The newest revision in migrations/versions. Each migration PR updates it, and +# tests/test_schema.py fails when it differs from the chain's head. The +# readiness check reports it next to the database's revision. +HEAD_REVISION = "0001" NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", diff --git a/libs/policyengine-simulation-persistence/tests/conftest.py b/libs/policyengine-simulation-persistence/tests/conftest.py new file mode 100644 index 000000000..1726049dc --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/conftest.py @@ -0,0 +1,116 @@ +"""Disposable PostgreSQL databases for the migration and store tests. + +Tests marked ``postgres`` need a PostgreSQL server whose superuser URL is in +``SIMULATION_TEST_DATABASE_URL``. Each test gets a new database owned by a +non-superuser migrator role, so migrations run with the privileges they have in +production. Without the variable those tests skip, unless +``SIMULATION_REQUIRE_DATABASE_TESTS=1`` (set in CI), where they fail instead. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +from migration_support import ( + MIGRATION_DATABASE_URL_VARIABLE, + MIGRATOR_ROLE, + TEST_ROLE_PASSWORD, +) +from policyengine_simulation_persistence.schema import RUNTIME_ROLE +from sqlalchemy import create_engine, text +from sqlalchemy.engine import URL, make_url + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy import Connection + +DATABASE_URL_VARIABLE = "SIMULATION_TEST_DATABASE_URL" +REQUIRE_DATABASE_VARIABLE = "SIMULATION_REQUIRE_DATABASE_TESTS" + + +@dataclass(frozen=True) +class DisposableDatabase: + """One empty database and the URLs of the roles that use it.""" + + name: str + migrator_url: str + runtime_url: str + superuser_url: str + + +def _canonical(url: URL) -> str: + return url.set(drivername="postgresql").render_as_string(hide_password=False) + + +def _ensure_login_role(connection: Connection, role_name: str) -> None: + connection.execute( + text( + "DO $$ BEGIN " + f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{role_name}') " + f"THEN CREATE ROLE {role_name} LOGIN PASSWORD '{TEST_ROLE_PASSWORD}'; " + "END IF; END $$" + ) + ) + + +@pytest.fixture(scope="session") +def postgres_server_url() -> URL: + """Return the superuser URL of the test server, or skip without one.""" + configured = os.environ.get(DATABASE_URL_VARIABLE, "") + if not configured: + message = f"{DATABASE_URL_VARIABLE} names no PostgreSQL server" + if os.environ.get(REQUIRE_DATABASE_VARIABLE) == "1": + pytest.fail(message) + pytest.skip(message) + return make_url(configured).set(drivername="postgresql+psycopg") + + +@pytest.fixture +def disposable_database( + postgres_server_url: URL, + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[DisposableDatabase]: + """Create an empty database owned by the migrator, and drop it afterwards. + + ``SIMULATION_MIGRATION_DATABASE_URL`` points at it, as in the deploy job. + """ + database_name = f"simulation_test_{uuid4().hex[:12]}" + server_engine = create_engine(postgres_server_url, isolation_level="AUTOCOMMIT") + try: + with server_engine.connect() as connection: + _ensure_login_role(connection, MIGRATOR_ROLE) + _ensure_login_role(connection, RUNTIME_ROLE) + connection.execute( + text(f'CREATE DATABASE "{database_name}" OWNER {MIGRATOR_ROLE}') + ) + database = DisposableDatabase( + name=database_name, + migrator_url=_canonical( + postgres_server_url.set( + username=MIGRATOR_ROLE, + password=TEST_ROLE_PASSWORD, + database=database_name, + ) + ), + runtime_url=_canonical( + postgres_server_url.set( + username=RUNTIME_ROLE, + password=TEST_ROLE_PASSWORD, + database=database_name, + ) + ), + superuser_url=_canonical(postgres_server_url.set(database=database_name)), + ) + monkeypatch.setenv(MIGRATION_DATABASE_URL_VARIABLE, database.migrator_url) + yield database + finally: + with server_engine.connect() as connection: + connection.execute( + text(f'DROP DATABASE IF EXISTS "{database_name}" WITH (FORCE)') + ) + server_engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/migration_support.py b/libs/policyengine-simulation-persistence/tests/migration_support.py new file mode 100644 index 000000000..fde328bed --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/migration_support.py @@ -0,0 +1,18 @@ +"""Paths and names shared by the migration and store tests.""" + +from __future__ import annotations + +from pathlib import Path + +from alembic.config import Config + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +ALEMBIC_CONFIG_PATH = REPOSITORY_ROOT / "migrations" / "alembic.ini" +MIGRATION_DATABASE_URL_VARIABLE = "SIMULATION_MIGRATION_DATABASE_URL" +MIGRATOR_ROLE = "simulation_migrator" +TEST_ROLE_PASSWORD = "simulation-test-password" + + +def alembic_config() -> Config: + """Return the repository's Alembic configuration.""" + return Config(str(ALEMBIC_CONFIG_PATH)) diff --git a/libs/policyengine-simulation-persistence/tests/ownership_checks.py b/libs/policyengine-simulation-persistence/tests/ownership_checks.py new file mode 100644 index 000000000..5e2e0d9bf --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/ownership_checks.py @@ -0,0 +1,216 @@ +"""Checks behind the simulation schema ownership rules in rules/registry.toml. + +Each check takes the files it inspects, so the rule tests run it on the +repository and the proof tests run it on the fixtures under +``rules/fixtures//``. Runtime code that changes a schema is caught by +sim.boundaries.sqlalchemy-and-alembic-only, not here. +""" + +from __future__ import annotations + +import ast +import configparser +import re +from pathlib import Path +from typing import TYPE_CHECKING + +import tomllib +import yaml + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping + +CHAIN_DIRECTORY = Path("migrations") +CHAIN_CONFIG = CHAIN_DIRECTORY / "alembic.ini" +CHAIN_REVISIONS = CHAIN_DIRECTORY / "versions" +SIMULATION_SCHEMA = "simulation" +IGNORED_DIRECTORY_NAMES = frozenset( + {".git", ".venv", "node_modules", "__pycache__", ".pytest_cache", ".ruff_cache"} +) +# Proof fixtures deliberately contain bad layouts. +IGNORED_RELATIVE_DIRECTORIES = (Path("rules") / "fixtures",) +PERSISTENCE_DISTRIBUTION = "policyengine-simulation-persistence" +PERSISTENCE_PACKAGE = "policyengine_simulation_persistence" +SIMULATION_DATABASE_CREDENTIAL = re.compile(r"SIMULATION_(?:MIGRATION_)?DATABASE_URL") +MODAL_SECRET_COMMAND = re.compile(r"\bmodal\s+secret\s+create\b") +MODAL_DEPLOY_COMMAND = re.compile(r"\bmodal\s+deploy\b") + + +def _repository_files(root: Path, pattern: str) -> Iterator[Path]: + for path in sorted(root.rglob(pattern)): + relative = path.relative_to(root) + if IGNORED_DIRECTORY_NAMES.intersection(relative.parts): + continue + if any( + relative.is_relative_to(ignored) for ignored in IGNORED_RELATIVE_DIRECTORIES + ): + continue + yield path + + +def _configures_simulation_version_table(environment_source: str) -> bool: + for node in ast.walk(ast.parse(environment_source)): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "configure" + ): + continue + for keyword in node.keywords: + if keyword.arg != "version_table_schema": + continue + value = keyword.value + if isinstance(value, ast.Name) and value.id == "SIMULATION_SCHEMA": + return True + if isinstance(value, ast.Constant) and value.value == SIMULATION_SCHEMA: + return True + return False + + +def _layout_violations(root: Path) -> list[str]: + violations: list[str] = [] + configurations = [ + path.relative_to(root) for path in _repository_files(root, "alembic*.ini") + ] + if configurations != [CHAIN_CONFIG]: + violations.append( + f"expected only {CHAIN_CONFIG} as an Alembic configuration, found " + f"{[str(path) for path in configurations]}" + ) + for versions in _repository_files(root, "versions"): + relative = versions.relative_to(root) + if ( + versions.is_dir() + and relative != CHAIN_REVISIONS + and any(versions.glob("*.py")) + ): + violations.append(f"revision directory outside the chain: {relative}") + for environment in _repository_files(root, "env.py"): + relative = environment.relative_to(root) + if relative != CHAIN_DIRECTORY / "env.py" and "alembic" in ( + environment.read_text(encoding="utf-8") + ): + violations.append(f"Alembic environment outside the chain: {relative}") + return violations + + +def migration_chain_violations(root: Path) -> list[str]: + """Return how ``root`` departs from one simulation chain at ``migrations/``.""" + root = root.resolve() + violations = _layout_violations(root) + config_path = root / CHAIN_CONFIG + if not config_path.is_file(): + return violations + parser = configparser.ConfigParser(interpolation=None) + parser.read(config_path, encoding="utf-8") + script_location = parser.get("alembic", "script_location", fallback="") + resolved = Path(script_location.replace("%(here)s", str(config_path.parent))) + if not resolved.is_absolute(): + resolved = root / resolved + if resolved.resolve() != (root / CHAIN_DIRECTORY).resolve(): + violations.append(f"script_location {script_location!r} is not migrations/") + environment_path = root / CHAIN_DIRECTORY / "env.py" + if not environment_path.is_file() or not _configures_simulation_version_table( + environment_path.read_text(encoding="utf-8") + ): + violations.append( + "migrations/env.py must keep its version table in the simulation schema" + ) + return violations + + +def declares_persistence_dependency(pyproject_text: str) -> bool: + """Return whether a pyproject declares the persistence library anywhere.""" + project = tomllib.loads(pyproject_text).get("project", {}) + requirements = [ + *project.get("dependencies", []), + *( + requirement + for group in project.get("optional-dependencies", {}).values() + for requirement in group + ), + ] + return any( + re.split(r"[\s\[<>=!~;]", requirement, maxsplit=1)[0] + == PERSISTENCE_DISTRIBUTION + for requirement in requirements + ) + + +def persistence_dependency_violations(project_root: Path) -> list[str]: + """Return where a project depends on or imports the persistence library.""" + violations: list[str] = [] + pyproject = project_root / "pyproject.toml" + if declares_persistence_dependency(pyproject.read_text(encoding="utf-8")): + violations.append(f"{pyproject} declares {PERSISTENCE_DISTRIBUTION}") + lock = project_root / "uv.lock" + if lock.is_file() and f'name = "{PERSISTENCE_DISTRIBUTION}"' in lock.read_text( + encoding="utf-8" + ): + violations.append(f"{lock} locks {PERSISTENCE_DISTRIBUTION}") + violations.extend( + f"{path} imports {PERSISTENCE_PACKAGE}" + for path in sorted((project_root / "src").rglob("*.py")) + if PERSISTENCE_PACKAGE in path.read_text(encoding="utf-8") + ) + return violations + + +def _step_text(step: Mapping[str, object]) -> str: + return "\n".join( + str(value) + for value in (step.get("run", ""), step.get("uses", ""), step.get("with", "")) + ) + + +def _step_reaches_modal(step_text: str, modal_scripts: set[str]) -> bool: + return bool( + MODAL_SECRET_COMMAND.search(step_text) + or MODAL_DEPLOY_COMMAND.search(step_text) + or any(name in step_text for name in modal_scripts) + ) + + +def _step_carries_credential(step: Mapping[str, object], step_text: str) -> bool: + environment = step.get("env") or {} + assert isinstance(environment, dict) + return bool(SIMULATION_DATABASE_CREDENTIAL.search(step_text)) or any( + SIMULATION_DATABASE_CREDENTIAL.search(f"{key}={value}") + for key, value in environment.items() + ) + + +def modal_credential_violations( + workflow_texts: Mapping[str, str], + script_texts: Mapping[str, str], +) -> list[str]: + """Return Modal secret or deploy steps that carry simulation database URLs. + + ``workflow_texts`` and ``script_texts`` map a file name to its contents. A + step counts as giving Modal a value when it creates a Modal secret or + deploys a Modal application, directly or through a script that does. + """ + modal_scripts = { + name + for name, text in script_texts.items() + if MODAL_SECRET_COMMAND.search(text) or MODAL_DEPLOY_COMMAND.search(text) + } + violations = [ + f"{name} gives Modal a simulation database credential" + for name in sorted(modal_scripts) + if SIMULATION_DATABASE_CREDENTIAL.search(script_texts[name]) + ] + for workflow_name, workflow_text in sorted(workflow_texts.items()): + workflow = yaml.safe_load(workflow_text) or {} + for job_name, job in (workflow.get("jobs") or {}).items(): + for step in job.get("steps") or []: + step_text = _step_text(step) + if _step_reaches_modal( + step_text, modal_scripts + ) and _step_carries_credential(step, step_text): + violations.append( + f"{workflow_name} job {job_name} step " + f"{step.get('name', '')!r} gives Modal a " + "simulation database credential" + ) + return violations diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py new file mode 100644 index 000000000..ef636033d --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -0,0 +1,166 @@ +"""Run the simulation migration chain against a fresh PostgreSQL database.""" + +from __future__ import annotations + +import pytest +from alembic import command +from migration_support import MIGRATOR_ROLE, alembic_config +from policyengine_simulation_persistence import ( + HEAD_REVISION, + SIMULATION_SCHEMA, + create_simulation_engine, + read_schema_revision, +) +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.exc import ProgrammingError + +pytestmark = pytest.mark.postgres + + +def _revision(database_url: str) -> str | None: + engine = create_simulation_engine(database_url) + try: + with engine.connect() as connection: + return read_schema_revision(connection) + finally: + engine.dispose() + + +def _superuser_engine(database, **options): + return create_engine( + database.superuser_url.replace("postgresql://", "postgresql+psycopg://", 1), + **options, + ) + + +def test_upgrade_check_downgrade_upgrade_cycle(disposable_database) -> None: + config = alembic_config() + + command.upgrade(config, "head") + assert _revision(disposable_database.migrator_url) == HEAD_REVISION + command.check(config) + + command.downgrade(config, "base") + assert _revision(disposable_database.migrator_url) is None + + command.upgrade(config, "head") + assert _revision(disposable_database.migrator_url) == HEAD_REVISION + command.check(config) + + +def test_the_version_table_lives_only_in_the_simulation_schema( + disposable_database, +) -> None: + command.upgrade(alembic_config(), "head") + + engine = _superuser_engine(disposable_database) + try: + with engine.connect() as connection: + inspector = inspect(connection) + assert inspector.has_table("alembic_version", schema=SIMULATION_SCHEMA) + assert not inspector.has_table("alembic_version", schema="public") + schema_owner = connection.execute( + text( + "SELECT pg_get_userbyid(nspowner) FROM pg_namespace " + "WHERE nspname = :schema" + ), + {"schema": SIMULATION_SCHEMA}, + ).scalar() + finally: + engine.dispose() + assert schema_owner == MIGRATOR_ROLE + + +def test_an_unmigrated_database_reports_no_revision(disposable_database) -> None: + assert _revision(disposable_database.migrator_url) is None + + +def _precreate_schema_without_database_create(database) -> None: + """Set up the operator alternative to granting the migrator CREATE. + + The superuser takes over the database, so the migrator keeps only the + default CONNECT and TEMPORARY privileges, and creates the simulation + schema owned by the migrator. + """ + engine = _superuser_engine(database, isolation_level="AUTOCOMMIT") + try: + with engine.connect() as connection: + connection.execute( + text(f'ALTER DATABASE "{database.name}" OWNER TO CURRENT_USER') + ) + connection.execute( + text(f"CREATE SCHEMA {SIMULATION_SCHEMA} AUTHORIZATION {MIGRATOR_ROLE}") + ) + migrator_may_create = connection.execute( + text( + "SELECT has_database_privilege(:role, current_database(), 'CREATE')" + ), + {"role": MIGRATOR_ROLE}, + ).scalar() + finally: + engine.dispose() + assert migrator_may_create is False + + +def test_a_precreated_schema_needs_no_database_create_privilege( + disposable_database, +) -> None: + _precreate_schema_without_database_create(disposable_database) + + command.upgrade(alembic_config(), "head") + + assert _revision(disposable_database.migrator_url) == HEAD_REVISION + assert _revision(disposable_database.runtime_url) == HEAD_REVISION + + +def test_the_runtime_role_reads_the_revision_but_cannot_change_the_schema( + disposable_database, +) -> None: + command.upgrade(alembic_config(), "head") + + assert _revision(disposable_database.runtime_url) == HEAD_REVISION + engine = create_simulation_engine(disposable_database.runtime_url) + try: + with ( + pytest.raises(ProgrammingError, match="permission denied"), + engine.begin() as connection, + ): + connection.execute( + text("CREATE TABLE simulation.unowned (identifier integer)") + ) + with ( + pytest.raises(ProgrammingError, match="permission denied"), + engine.begin() as connection, + ): + connection.execute( + text("UPDATE simulation.alembic_version SET version_num = ''") + ) + finally: + engine.dispose() + + +def test_a_failed_revision_leaves_nothing_applied( + disposable_database, monkeypatch +) -> None: + """The schema and every revision apply in one transaction.""" + from alembic.runtime import migration + + def fail_after_creating_the_schema(*_arguments, **_keywords): + msg = "simulated failure inside the migration transaction" + raise RuntimeError(msg) + + monkeypatch.setattr( + migration.MigrationContext, + "run_migrations", + fail_after_creating_the_schema, + ) + + with pytest.raises(RuntimeError, match="simulated failure"): + command.upgrade(alembic_config(), "head") + + engine = _superuser_engine(disposable_database) + try: + with engine.connect() as connection: + assert not inspect(connection).has_schema(SIMULATION_SCHEMA) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py new file mode 100644 index 000000000..8d3b3d8ba --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py @@ -0,0 +1,89 @@ +"""Simulation schema ownership: one chain, no executor dependency, none in Modal.""" + +from __future__ import annotations + +from migration_support import REPOSITORY_ROOT +from ownership_checks import ( + declares_persistence_dependency, + migration_chain_violations, + modal_credential_violations, + persistence_dependency_violations, +) + +FIXTURES = REPOSITORY_ROOT / "rules" / "fixtures" +WORKFLOWS = REPOSITORY_ROOT / ".github" / "workflows" +WORKFLOW_SCRIPTS = REPOSITORY_ROOT / ".github" / "scripts" +MODAL_PROJECTS = ( + REPOSITORY_ROOT / "projects" / "policyengine-simulation-executor", + REPOSITORY_ROOT / "projects" / "policyengine-simulation-gateway", +) +SIMULATION_DATABASE_VARIABLES = ( + "SIMULATION_DATABASE_URL", + "SIMULATION_MIGRATION_DATABASE_URL", +) + + +def _texts(directory, pattern): + return { + path.name: path.read_text(encoding="utf-8") + for path in sorted(directory.glob(pattern)) + } + + +def test_repository_has_one_simulation_migration_chain() -> None: + assert migration_chain_violations(REPOSITORY_ROOT) == [] + + +def test_rule_proof_one_migration_chain() -> None: + """Proof for rule sim.persistence.one-migration-chain (rules/registry.toml).""" + fixtures = FIXTURES / "sim.persistence.one-migration-chain" + + assert migration_chain_violations(fixtures / "bad_repository") != [] + assert migration_chain_violations(fixtures / "good_repository") == [] + + +def test_modal_projects_never_depend_on_the_persistence_library() -> None: + for project_root in MODAL_PROJECTS: + assert persistence_dependency_violations(project_root) == [] + + +def test_rule_proof_executor_independent() -> None: + """Proof for rule sim.persistence.executor-independent (rules/registry.toml).""" + fixtures = FIXTURES / "sim.persistence.executor-independent" + + assert declares_persistence_dependency( + (fixtures / "bad_pyproject.toml").read_text(encoding="utf-8") + ) + assert not declares_persistence_dependency( + (fixtures / "good_pyproject.toml").read_text(encoding="utf-8") + ) + + +def test_modal_never_receives_simulation_database_credentials() -> None: + assert ( + modal_credential_violations( + _texts(WORKFLOWS, "*.yml"), + _texts(WORKFLOW_SCRIPTS, "*.sh"), + ) + == [] + ) + for project_root in MODAL_PROJECTS: + for path in sorted((project_root / "src").rglob("*.py")): + source = path.read_text(encoding="utf-8") + for variable in SIMULATION_DATABASE_VARIABLES: + assert variable not in source, path + + +def test_rule_proof_no_modal_database_credentials() -> None: + """Proof for rule sim.persistence.no-modal-database-credentials.""" + fixtures = FIXTURES / "sim.persistence.no-modal-database-credentials" + no_scripts: dict[str, str] = {} + + assert modal_credential_violations( + {"bad.yml": (fixtures / "bad_workflow.yml").read_text(encoding="utf-8")}, + no_scripts, + ) + assert not modal_credential_violations( + {"good.yml": (fixtures / "good_workflow.yml").read_text(encoding="utf-8")}, + no_scripts, + ) diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py index f6e40a4c1..d42b8bf4d 100644 --- a/libs/policyengine-simulation-persistence/tests/test_schema.py +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -3,7 +3,11 @@ from __future__ import annotations import pytest +from alembic import command +from alembic.script import ScriptDirectory +from migration_support import MIGRATION_DATABASE_URL_VARIABLE, alembic_config from policyengine_simulation_persistence import ( + HEAD_REVISION, RUNTIME_ROLE, SIMULATION_SCHEMA, create_simulation_engine, @@ -75,3 +79,28 @@ def capture_engine(url, **options): assert captured_options["connect_args"]["connect_timeout"] > 0 assert captured_options["pool_pre_ping"] is True assert captured_options["pool_size"] >= 1 + + +def test_head_revision_names_the_single_head_of_the_chain() -> None: + script = ScriptDirectory.from_config(alembic_config()) + + assert script.get_heads() == [HEAD_REVISION] + + +def test_migrations_refuse_to_run_without_the_migration_database_url( + monkeypatch, +) -> None: + monkeypatch.delenv(MIGRATION_DATABASE_URL_VARIABLE, raising=False) + + with pytest.raises(RuntimeError, match=MIGRATION_DATABASE_URL_VARIABLE): + command.upgrade(alembic_config(), "head") + + +def test_migrations_refuse_offline_sql_generation(monkeypatch) -> None: + monkeypatch.setenv( + MIGRATION_DATABASE_URL_VARIABLE, + "postgresql://simulation_migrator:secret@db.example/postgres", + ) + + with pytest.raises(RuntimeError, match="online"): + command.upgrade(alembic_config(), "head", sql=True) diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock index e49da7694..a617c4024 100644 --- a/libs/policyengine-simulation-persistence/uv.lock +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -2,6 +2,20 @@ version = 1 revision = 5 requires-python = "==3.13.*" +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -44,6 +58,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, +] + [[package]] name = "nodeenv" version = "1.11.0" @@ -84,20 +138,28 @@ dependencies = [ build = [ { name = "pyright" }, ] +migrations = [ + { name = "alembic" }, +] test = [ + { name = "alembic" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, ] [package.metadata] requires-dist = [ + { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.16,<2" }, + { name = "alembic", marker = "extra == 'test'", specifier = ">=1.16,<2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, + { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0.2" }, { name = "sqlalchemy", specifier = ">=2,<3" }, ] -provides-extras = ["test", "build"] +provides-extras = ["migrations", "test", "build"] [[package]] name = "psycopg" @@ -186,6 +248,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.6" diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 000000000..fea6f84b2 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,44 @@ +# The Simulation Entrypoint's only Alembic chain: the `simulation` schema. +# Run it from the persistence library's environment, for example: +# uv run --project libs/policyengine-simulation-persistence --extra migrations \ +# alembic -c migrations/alembic.ini upgrade head +# The database URL comes only from SIMULATION_MIGRATION_DATABASE_URL. +# Ownership rules: libs/policyengine-simulation-persistence/README.md + +[alembic] +script_location = %(here)s +path_separator = os +file_template = %%(rev)s_%%(slug)s + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 000000000..c321abeca --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,108 @@ +"""Alembic environment for the Simulation Entrypoint's ``simulation`` schema.""" + +from __future__ import annotations + +import os +from logging.config import fileConfig +from typing import TYPE_CHECKING + +from alembic import context +from policyengine_simulation_persistence import ( + ALEMBIC_VERSION_TABLE, + SIMULATION_SCHEMA, + normalized_database_url, + simulation_metadata, +) +from sqlalchemy import create_engine, inspect, pool +from sqlalchemy.schema import CreateSchema + +if TYPE_CHECKING: + from sqlalchemy import Connection + +DATABASE_URL_VARIABLE = "SIMULATION_MIGRATION_DATABASE_URL" +CONNECT_TIMEOUT_SECONDS = 10 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name, disable_existing_loggers=False) + + +def _include_name(name: str | None, type_: str, _parent_names: object) -> bool: + """Compare only the simulation schema, never other services' schemas.""" + if type_ == "schema": + return name == SIMULATION_SCHEMA + return True + + +def _include_object( + _object: object, + name: str | None, + type_: str, + _reflected: object, + _compare_to: object, +) -> bool: + """Leave Alembic's own version table out of the schema comparison.""" + return not (type_ == "table" and name == ALEMBIC_VERSION_TABLE) + + +def _ensure_simulation_schema(connection: Connection) -> None: + """Create the simulation schema, owned by the migrator, if it is missing. + + PostgreSQL checks CREATE on the database before it checks whether a schema + exists, even for CREATE SCHEMA IF NOT EXISTS. Creating the schema only when + it is missing lets an operator create it in advance, owned by the migrator, + instead of granting the migrator CREATE on the database. + """ + if not inspect(connection).has_schema(SIMULATION_SCHEMA): + connection.execute(CreateSchema(SIMULATION_SCHEMA)) + + +def _run(connection: Connection) -> None: + # The version table lives in the simulation schema, so the schema must + # exist before Alembic looks for it. + _ensure_simulation_schema(connection) + context.configure( + connection=connection, + target_metadata=simulation_metadata, + version_table=ALEMBIC_VERSION_TABLE, + version_table_schema=SIMULATION_SCHEMA, + include_schemas=True, + include_name=_include_name, + include_object=_include_object, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Apply migrations to the database named by the migration URL.""" + database_url = os.environ.get(DATABASE_URL_VARIABLE, "") + if not database_url: + msg = f"{DATABASE_URL_VARIABLE} must name the simulation database" + raise RuntimeError(msg) + engine = create_engine( + normalized_database_url(database_url), + poolclass=pool.NullPool, + connect_args={ + "connect_timeout": CONNECT_TIMEOUT_SECONDS, + "prepare_threshold": None, + }, + ) + try: + # One transaction covers the schema creation and every revision, so a + # failed migration leaves nothing half-applied. + with engine.begin() as connection: + _run(connection) + finally: + engine.dispose() + + +if context.is_offline_mode(): + msg = ( + "Simulation migrations run only online, against the database they " + "change; offline SQL generation is not supported" + ) + raise RuntimeError(msg) +run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 000000000..a56f67cc9 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + """Apply this revision.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Revert this revision.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0001_initial_simulation_schema.py b/migrations/versions/0001_initial_simulation_schema.py new file mode 100644 index 000000000..038498dd3 --- /dev/null +++ b/migrations/versions/0001_initial_simulation_schema.py @@ -0,0 +1,52 @@ +"""Grant the Simulation Entrypoint runtime role access to the simulation schema. + +The environment creates the ``simulation`` schema, owned by the migrator, and +Alembic creates ``simulation.alembic_version`` before this revision runs. This +revision lets the runtime role use the schema, read the applied revision for +the readiness check, and read and write every table the migrator creates in +later revisions. The runtime role never owns or changes schema objects. + +Revision ID: 0001 +Revises: +Create Date: 2026-10-22 +""" + +from __future__ import annotations + +from alembic import op + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + +# The statements name the schema and role literally: a migration is a fixed +# historical record, so later renames must not change what it did. +UPGRADE_STATEMENTS = ( + "GRANT USAGE ON SCHEMA simulation TO simulation_runtime", + "GRANT SELECT ON simulation.alembic_version TO simulation_runtime", + "ALTER DEFAULT PRIVILEGES IN SCHEMA simulation " + "GRANT SELECT, INSERT, UPDATE ON TABLES TO simulation_runtime", + "ALTER DEFAULT PRIVILEGES IN SCHEMA simulation " + "GRANT USAGE, SELECT ON SEQUENCES TO simulation_runtime", +) +DOWNGRADE_STATEMENTS = ( + "ALTER DEFAULT PRIVILEGES IN SCHEMA simulation " + "REVOKE USAGE, SELECT ON SEQUENCES FROM simulation_runtime", + "ALTER DEFAULT PRIVILEGES IN SCHEMA simulation " + "REVOKE SELECT, INSERT, UPDATE ON TABLES FROM simulation_runtime", + "REVOKE SELECT ON simulation.alembic_version FROM simulation_runtime", + "REVOKE USAGE ON SCHEMA simulation FROM simulation_runtime", +) + + +def upgrade() -> None: + """Grant schema usage, revision reads and default table privileges.""" + for statement in UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + """Revoke everything ``upgrade`` granted.""" + for statement in DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py b/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py index 18293a918..ad60f091d 100644 --- a/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py +++ b/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py @@ -23,8 +23,9 @@ / "sim.boundaries.sqlalchemy-and-alembic-only" ) # Every directory that holds non-test source. The executor's src/modal is -# under projects/*/src. -SOURCE_ROOT_PATTERNS = ("projects/*/src", "libs/*/src", ".github/scripts") +# under projects/*/src. The simulation schema's Alembic chain may import +# alembic and change the schema, but still connects only through SQLAlchemy. +SOURCE_ROOT_PATTERNS = ("projects/*/src", "libs/*/src", ".github/scripts", "migrations") # Directories the rule names, each of which must contribute scanned files. REQUIRED_SOURCE_DIRECTORIES = ( "projects/policyengine-simulation-entry/src", @@ -37,6 +38,7 @@ "libs/policyengine-simulation-persistence/src", "libs/policyengine-stage12-persistence/src", ".github/scripts", + "migrations", ) diff --git a/projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py b/projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py index daf0a01c1..25e967219 100644 --- a/projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py +++ b/projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py @@ -2,7 +2,9 @@ DDL_PATTERN is shared with sim.boundaries.sqlalchemy-and-alembic-only, which matches it against the string literals of every source directory; see -database_access_support.py. +database_access_support.py. The Simulation Entrypoint's own schema has its own +ownership rules, checked in +libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py. """ from __future__ import annotations @@ -41,12 +43,6 @@ def test_rule_proof_stage12_no_schema_ddl() -> None: assert DDL_PATTERN.search(good) is None -def test_simulation_repository_has_no_schema_migration_chain() -> None: - assert not list(REPOSITORY_ROOT.glob("alembic*.ini")) - assert not list(REPOSITORY_ROOT.rglob("alembic/versions")) - assert not list(REPOSITORY_ROOT.rglob("migrations/versions")) - - def test_stage12_runtime_cannot_create_production_user_associations() -> None: forbidden = { "user_simulation", diff --git a/ruff.toml b/ruff.toml index 38c26d845..6cc798b35 100644 --- a/ruff.toml +++ b/ruff.toml @@ -9,7 +9,8 @@ per-file-target-version = { "scripts/**" = "py311", "projects/policyengine-simulation-executor/**" = "py313" } # Directories that are not regular packages on purpose, so they have no # __init__.py: standalone scripts, the implicit namespace package that holds -# scripts.rules, and proof fixtures. Ruff resolves modules as if each listed +# scripts.rules, proof fixtures, and the Alembic chain, whose environment and +# revisions Alembic loads by path. Ruff resolves modules as if each listed # directory, and every directory under it, had an __init__.py. This is a # top-level setting: ruff rejects it in [lint]. The executor's src/modal/utils # scripts are not listed, because ruff would then treat them as part of a @@ -17,6 +18,7 @@ per-file-target-version = { "scripts/**" = "py311", "projects/policyengine-simul # an inline INP001 suppression instead. namespace-packages = [ ".github/scripts", + "migrations", "projects/policyengine-simulation-executor/scripts", "rules/fixtures", "scripts", diff --git a/rules/fixtures/sim.persistence.executor-independent/bad_pyproject.toml b/rules/fixtures/sim.persistence.executor-independent/bad_pyproject.toml new file mode 100644 index 000000000..cb1ae0f5a --- /dev/null +++ b/rules/fixtures/sim.persistence.executor-independent/bad_pyproject.toml @@ -0,0 +1,7 @@ +# Proof fixture: a Modal project that depends on the persistence library. +[project] +name = "policyengine-simulation-executor" +dependencies = [ + "policyengine-simulation-contract", + "policyengine-simulation-persistence", +] diff --git a/rules/fixtures/sim.persistence.executor-independent/good_pyproject.toml b/rules/fixtures/sim.persistence.executor-independent/good_pyproject.toml new file mode 100644 index 000000000..b75b2ff09 --- /dev/null +++ b/rules/fixtures/sim.persistence.executor-independent/good_pyproject.toml @@ -0,0 +1,6 @@ +# Proof fixture: a Modal project that depends only on the contract library. +[project] +name = "policyengine-simulation-executor" +dependencies = [ + "policyengine-simulation-contract", +] diff --git a/rules/fixtures/sim.persistence.no-modal-database-credentials/bad_workflow.yml b/rules/fixtures/sim.persistence.no-modal-database-credentials/bad_workflow.yml new file mode 100644 index 000000000..739dbc28b --- /dev/null +++ b/rules/fixtures/sim.persistence.no-modal-database-credentials/bad_workflow.yml @@ -0,0 +1,11 @@ +# Proof fixture: a step that copies the simulation database URL into a Modal secret. +name: Bad deployment +on: workflow_dispatch +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Synchronize Modal runtime secrets + env: + SIMULATION_DATABASE_URL: ${{ secrets.SIMULATION_DATABASE_URL }} + run: uv run modal secret create simulation-database "SIMULATION_DATABASE_URL=${SIMULATION_DATABASE_URL}" --force diff --git a/rules/fixtures/sim.persistence.no-modal-database-credentials/good_workflow.yml b/rules/fixtures/sim.persistence.no-modal-database-credentials/good_workflow.yml new file mode 100644 index 000000000..9a2318ce2 --- /dev/null +++ b/rules/fixtures/sim.persistence.no-modal-database-credentials/good_workflow.yml @@ -0,0 +1,16 @@ +# Proof fixture: Modal receives only the Stage 12 secret name; the simulation +# migration URL reaches the migration step alone. +name: Good deployment +on: workflow_dispatch +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Synchronize the separate Stage 12 runtime secret + env: + STAGE12_DATABASE_URL_SECRET_NAME: ${{ vars.STAGE12_DATABASE_URL_SECRET_NAME }} + run: uv run modal secret create stage12-runtime "STAGE12_DATABASE_URL_SECRET_NAME=${STAGE12_DATABASE_URL_SECRET_NAME}" --force + - name: Apply simulation schema migrations + env: + SIMULATION_MIGRATION_DATABASE_URL: ${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }} + run: .github/scripts/simulation-database-migrate.sh diff --git a/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/alembic.ini b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/alembic.ini new file mode 100644 index 000000000..6cb09e697 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/alembic.ini @@ -0,0 +1,2 @@ +[alembic] +script_location = %(here)s diff --git a/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/env.py b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/env.py new file mode 100644 index 000000000..ab2608882 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/env.py @@ -0,0 +1,7 @@ +"""Proof fixture: an Alembic environment that versions the simulation schema.""" + +from alembic import context + +SIMULATION_SCHEMA = "simulation" + +context.configure(version_table_schema=SIMULATION_SCHEMA) diff --git a/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/versions/0001_initial.py b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/versions/0001_initial.py new file mode 100644 index 000000000..5d6301f03 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/migrations/versions/0001_initial.py @@ -0,0 +1,4 @@ +"""Proof fixture: the chain's first revision.""" + +revision = "0001" +down_revision = None diff --git a/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/alembic.ini b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/alembic.ini new file mode 100644 index 000000000..6f4ed3585 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/alembic.ini @@ -0,0 +1,2 @@ +[alembic] +script_location = %(here)s/migrations diff --git a/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/migrations/versions/0001_service.py b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/migrations/versions/0001_service.py new file mode 100644 index 000000000..3a3f03b05 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/bad_repository/projects/service/migrations/versions/0001_service.py @@ -0,0 +1,4 @@ +"""Proof fixture: a revision in a second, forbidden chain.""" + +revision = "0001" +down_revision = None diff --git a/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/alembic.ini b/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/alembic.ini new file mode 100644 index 000000000..6cb09e697 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/alembic.ini @@ -0,0 +1,2 @@ +[alembic] +script_location = %(here)s diff --git a/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/env.py b/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/env.py new file mode 100644 index 000000000..ab2608882 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/env.py @@ -0,0 +1,7 @@ +"""Proof fixture: an Alembic environment that versions the simulation schema.""" + +from alembic import context + +SIMULATION_SCHEMA = "simulation" + +context.configure(version_table_schema=SIMULATION_SCHEMA) diff --git a/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/versions/0001_initial.py b/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/versions/0001_initial.py new file mode 100644 index 000000000..5d6301f03 --- /dev/null +++ b/rules/fixtures/sim.persistence.one-migration-chain/good_repository/migrations/versions/0001_initial.py @@ -0,0 +1,4 @@ +"""Proof fixture: the chain's first revision.""" + +revision = "0001" +down_revision = None diff --git a/rules/registry.toml b/rules/registry.toml index 4fe7272bb..07bee73c6 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -21,6 +21,10 @@ title = "Service and package boundaries" id = "stage12" title = "Stage 12 persistence" +[[category]] +id = "persistence" +title = "Simulation persistence" + [[category]] id = "deployment" title = "Deployment pipeline" @@ -112,7 +116,7 @@ id = "sim.boundaries.sqlalchemy-and-alembic-only" category = "boundaries" statement = "Code connects to databases only through SQLAlchemy engines and connections, never by importing a database driver directly, and runtime code never changes the schema, which changes only through Alembic migrations." rationale = "docs/engineering/skills/database-access.md#rules" -scope = ["projects/*/src/**/*.py", "libs/*/src/**/*.py", ".github/scripts/**/*.py"] +scope = ["projects/*/src/**/*.py", "libs/*/src/**/*.py", ".github/scripts/**/*.py", "migrations/**/*.py"] tier = "mechanical" status = "enforced" autofix = false @@ -133,48 +137,123 @@ good_fixture = "rules/fixtures/sim.boundaries.sqlalchemy-and-alembic-only/good_d project = "projects/policyengine-simulation-entry" test = "tests/test_database_access_boundaries.py::test_rule_proof_sqlalchemy_and_alembic_only" -# --- Stage 12 persistence -------------------------------------------------- +# --- Simulation persistence ----------------------------------------------- [[rule]] -id = "sim.stage12.no-schema-ddl" -category = "stage12" -statement = "Simulation runtimes and the Stage 12 persistence library contain no schema DDL (CREATE, ALTER, DROP or TRUNCATE of tables, types, schemas or databases, or create_all); policyengine-api owns the schema." -rationale = "libs/policyengine-stage12-persistence/README.md" -scope = ["libs/policyengine-stage12-persistence/src/**/*.py", "projects/policyengine-simulation-entry/src/**/*.py", "projects/policyengine-simulation-executor/src/**/*.py"] +id = "sim.persistence.one-migration-chain" +category = "persistence" +statement = "The simulation schema changes only through the one Alembic chain at migrations/, configured by migrations/alembic.ini and versioned in simulation.alembic_version; no other Alembic configuration, environment or revision directory exists." +rationale = "libs/policyengine-simulation-persistence/README.md#ownership-rules" +scope = ["migrations/env.py", "migrations/alembic.ini", "migrations/versions/*.py"] tier = "mechanical" status = "enforced" autofix = false [rule.enforcer] kind = "pytest" -project = "projects/policyengine-simulation-entry" -nodes = ["tests/test_stage12_schema_authority.py::test_simulation_runtimes_contain_no_schema_ddl"] +project = "libs/policyengine-simulation-persistence" +nodes = ["tests/test_ownership_boundaries.py::test_repository_has_one_simulation_migration_chain"] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" [rule.proof] -bad_fixture = "rules/fixtures/sim.stage12.no-schema-ddl/bad_stage12_runtime.py" -good_fixture = "rules/fixtures/sim.stage12.no-schema-ddl/good_stage12_runtime.py" -project = "projects/policyengine-simulation-entry" -test = "tests/test_stage12_schema_authority.py::test_rule_proof_stage12_no_schema_ddl" +bad_fixture = "rules/fixtures/sim.persistence.one-migration-chain/bad_repository" +good_fixture = "rules/fixtures/sim.persistence.one-migration-chain/good_repository" +project = "libs/policyengine-simulation-persistence" +test = "tests/test_ownership_boundaries.py::test_rule_proof_one_migration_chain" [[rule]] -id = "sim.stage12.no-migration-chain" +id = "sim.persistence.migration-cycle" +category = "persistence" +statement = "On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema." +rationale = "libs/policyengine-simulation-persistence/README.md#tests" +scope = ["migrations/versions/*.py", "libs/policyengine-simulation-persistence/src/**/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "libs/policyengine-simulation-persistence" +nodes = [ + "tests/test_migration_cycle.py::test_upgrade_check_downgrade_upgrade_cycle", + "tests/test_migration_cycle.py::test_the_version_table_lives_only_in_the_simulation_schema", + "tests/test_migration_cycle.py::test_the_runtime_role_reads_the_revision_but_cannot_change_the_schema", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "simulation-database" +invocation = "uv run --frozen pytest tests/ -v -m postgres" + +[[rule]] +id = "sim.persistence.executor-independent" +category = "persistence" +statement = "The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it." +rationale = "libs/policyengine-simulation-persistence/README.md#ownership-rules" +scope = ["projects/policyengine-simulation-executor/pyproject.toml", "projects/policyengine-simulation-gateway/pyproject.toml", "projects/policyengine-simulation-executor/src/**/*.py", "projects/policyengine-simulation-gateway/src/**/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "libs/policyengine-simulation-persistence" +nodes = ["tests/test_ownership_boundaries.py::test_modal_projects_never_depend_on_the_persistence_library"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.persistence.executor-independent/bad_pyproject.toml" +good_fixture = "rules/fixtures/sim.persistence.executor-independent/good_pyproject.toml" +project = "libs/policyengine-simulation-persistence" +test = "tests/test_ownership_boundaries.py::test_rule_proof_executor_independent" + +[[rule]] +id = "sim.persistence.no-modal-database-credentials" +category = "persistence" +statement = "No Modal secret, Modal deployment or Modal application receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL; until the comparison path is removed, its own database secret is the only database credential Modal holds." +rationale = "libs/policyengine-simulation-persistence/README.md#ownership-rules" +scope = [".github/workflows/*.yml", ".github/scripts/*.sh", "projects/policyengine-simulation-executor/src/**/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "libs/policyengine-simulation-persistence" +nodes = ["tests/test_ownership_boundaries.py::test_modal_never_receives_simulation_database_credentials"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.persistence.no-modal-database-credentials/bad_workflow.yml" +good_fixture = "rules/fixtures/sim.persistence.no-modal-database-credentials/good_workflow.yml" +project = "libs/policyengine-simulation-persistence" +test = "tests/test_ownership_boundaries.py::test_rule_proof_no_modal_database_credentials" + +# --- Stage 12 persistence -------------------------------------------------- + +[[rule]] +id = "sim.stage12.no-schema-ddl" category = "stage12" -statement = "This repository has no schema migration chain (no Alembic); Stage 12 tables are migrated by policyengine-api." +statement = "Simulation runtimes and the Stage 12 persistence library contain no schema DDL (CREATE, ALTER, DROP or TRUNCATE of tables, types, schemas or databases, or create_all); policyengine-api owns the schema." rationale = "libs/policyengine-stage12-persistence/README.md" -scope = ["projects/*/src/**/*.py", "libs/*/src/**/*.py"] +scope = ["libs/policyengine-stage12-persistence/src/**/*.py", "projects/policyengine-simulation-entry/src/**/*.py", "projects/policyengine-simulation-executor/src/**/*.py"] tier = "mechanical" status = "enforced" autofix = false [rule.enforcer] kind = "pytest" project = "projects/policyengine-simulation-entry" -nodes = ["tests/test_stage12_schema_authority.py::test_simulation_repository_has_no_schema_migration_chain"] +nodes = ["tests/test_stage12_schema_authority.py::test_simulation_runtimes_contain_no_schema_ddl"] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.stage12.no-schema-ddl/bad_stage12_runtime.py" +good_fixture = "rules/fixtures/sim.stage12.no-schema-ddl/good_stage12_runtime.py" +project = "projects/policyengine-simulation-entry" +test = "tests/test_stage12_schema_authority.py::test_rule_proof_stage12_no_schema_ddl" [[rule]] id = "sim.stage12.no-production-user-associations" From a037ce2d9786158e316defc8b7f78ef7e9fbaf0a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:13:45 +0400 Subject: [PATCH 026/124] Report the simulation database's revision in the entrypoint readiness check The Simulation Entrypoint reads SIMULATION_DATABASE_URL, the runtime role's URL for its own schema. When it is set, the app builds a pooled engine (no connection opens until first use), disposes of it at shutdown, and GET /ready reports the database's applied migration revision next to HEAD_REVISION, the revision the image expects: {"status": "ready", "simulation_database": {"reachable": true, "revision": "0001", "expected_revision": "0001"}} The database does not change the readiness status, because no old-gateway or Stage 12 route uses it yet: an unreachable or slow database (bounded at three seconds) is reported as unreachable, logged with its error type only, and /ready keeps following the old gateway. Without the setting, /ready keeps its old shape. A URL that does not use the canonical postgresql:// scheme fails validation without repeating the URL. The entrypoint depends on the persistence library, so its image's build context now includes it; a new test checks that every local library the entrypoint installs is in the build context. Co-Authored-By: Claude Opus 5.5 --- docs/generated/rules.md | 2 +- .../Dockerfile.dockerignore | 2 + .../pyproject.toml | 2 + .../src/policyengine_simulation_entry/app.py | 66 ++++++- .../policyengine_simulation_entry/config.py | 14 ++ .../entry_runtime.py | 17 ++ .../policyengine_simulation_entry/schemas.py | 25 +++ .../simulation_database.py | 42 ++++ .../tests/test_config.py | 38 ++++ .../tests/test_deployment_assets_traffic.py | 23 +++ .../tests/test_openapi.py | 20 +- .../tests/test_simulation_database.py | 184 ++++++++++++++++++ .../policyengine-simulation-entry/uv.lock | 24 +++ rules/registry.toml | 5 +- 14 files changed, 453 insertions(+), 11 deletions(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py create mode 100644 projects/policyengine-simulation-entry/tests/test_simulation_database.py diff --git a/docs/generated/rules.md b/docs/generated/rules.md index dba75bacf..c50c886f4 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -166,7 +166,7 @@ flowchart LR | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | | --- | --- | --- | --- | --- | --- | --- | --- | -| `sim.deploy.container-context-minimal` | Container builds exclude local environments and unrelated projects, and the entrypoint container builds for the requested target platform. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.container-context-minimal` | Container builds exclude local environments and unrelated projects but include every local library the entrypoint installs, and the entrypoint container builds for the requested target platform. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** | | `sim.deploy.docker-images-build` | The simulation entry and executor Docker images build. | mechanical | enforced | yes | CI job `docker-build` | `pr.yml:docker-build` | — | | `sim.deploy.gcloud-without-terraform` | Deployment uses the gcloud-based workflow, not Terraform. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.image-smoke-covers-every-image-lib` | The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. | mechanical | doc-only | no | nothing | — | — | diff --git a/projects/policyengine-simulation-entry/Dockerfile.dockerignore b/projects/policyengine-simulation-entry/Dockerfile.dockerignore index 9379db178..a54f9060f 100644 --- a/projects/policyengine-simulation-entry/Dockerfile.dockerignore +++ b/projects/policyengine-simulation-entry/Dockerfile.dockerignore @@ -13,6 +13,8 @@ libs/* !libs/policyengine-simulation-contract/** !libs/policyengine-simulation-observability/ !libs/policyengine-simulation-observability/** +!libs/policyengine-simulation-persistence/ +!libs/policyengine-simulation-persistence/** !libs/policyengine-stage12-persistence/ !libs/policyengine-stage12-persistence/** libs/**/tests diff --git a/projects/policyengine-simulation-entry/pyproject.toml b/projects/policyengine-simulation-entry/pyproject.toml index 1b43b4f1d..11f569523 100644 --- a/projects/policyengine-simulation-entry/pyproject.toml +++ b/projects/policyengine-simulation-entry/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "policyengine-observability[fastapi,google,httpx,otlp-grpc]>=3.0.4,<4", "policyengine-simulation-contract", "policyengine-simulation-observability", + "policyengine-simulation-persistence", "policyengine-stage12-persistence", "pyjwt>=2.10.1,<3", "uvicorn>=0.35.0,<1", @@ -30,6 +31,7 @@ packages = ["src/policyengine_simulation_entry"] [tool.uv.sources] policyengine-simulation-contract = { path = "../../libs/policyengine-simulation-contract", editable = true } policyengine-simulation-observability = { path = "../../libs/policyengine-simulation-observability", editable = true } +policyengine-simulation-persistence = { path = "../../libs/policyengine-simulation-persistence", editable = true } policyengine-stage12-persistence = { path = "../../libs/policyengine-stage12-persistence", editable = true } [project.optional-dependencies] diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index be3a699ce..d862ebcd1 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -25,7 +25,6 @@ JobSubmitResponse, PingRequest, PingResponse, - ReadinessResponse, SimulationErrorResponse, SimulationRequest, VersionMap, @@ -42,6 +41,7 @@ from policyengine_simulation_observability.observability import ( init_simulation_observability, ) +from policyengine_simulation_persistence import HEAD_REVISION from starlette.datastructures import MutableHeaders from policyengine_simulation_entry.auth import ( @@ -59,15 +59,19 @@ ComparisonBackend, EntryRuntime, ResponseIdentifier, + SimulationDatabaseConnection, model_json, request_identifiers, route_template, set_observability_header, ) from policyengine_simulation_entry.schemas import ( + SimulationDatabaseReadiness, + SimulationEntrypointReadinessResponse, TemporaryStage12ReportResponse, TemporaryStage12SubmissionResponse, ) +from policyengine_simulation_entry.simulation_database import SimulationDatabase from policyengine_simulation_entry.stage12_backend import ( Stage12ComparisonBackend, TemporaryStage12DispatchFailed, @@ -84,11 +88,13 @@ "STAGE12_MODAL_SUBMISSION_TIMEOUT_SECONDS", "ComparisonBackend", "ResponseIdentifier", + "SimulationDatabaseConnection", "app", "create_app", ] logger = logging.getLogger(__name__) +SIMULATION_DATABASE_READINESS_TIMEOUT_SECONDS = 3.0 def _temporary_submission_payload( @@ -456,6 +462,34 @@ async def get_budget_window_job( ) +async def _simulation_database_readiness( + database: SimulationDatabaseConnection, +) -> SimulationDatabaseReadiness: + """Report the database's applied revision next to the image's.""" + try: + revision = await asyncio.wait_for( + database.read_revision(), + timeout=SIMULATION_DATABASE_READINESS_TIMEOUT_SECONDS, + ) + # Any failure to reach or read the database is reported, never raised: + # the old-gateway routes this check covers do not use it. + except Exception as error: # noqa: BLE001 # reported as unreachable + logger.warning( + "simulation_database_unreachable", + extra={"error_type": type(error).__name__}, + ) + return SimulationDatabaseReadiness( + reachable=False, + revision=None, + expected_revision=HEAD_REVISION, + ) + return SimulationDatabaseReadiness( + reachable=True, + revision=revision, + expected_revision=HEAD_REVISION, + ) + + def _register_service_routes(app: FastAPI, entry: EntryRuntime) -> None: """Add the public version, health, readiness and ping routes.""" @@ -488,14 +522,23 @@ async def get_country_versions(kind: str, request: Request) -> Response: async def health() -> HealthResponse: return HealthResponse() - @app.get("/ready", response_model=ReadinessResponse) + @app.get("/ready", response_model=SimulationEntrypointReadinessResponse) async def ready() -> Response: - if await entry.backend.ready(): - return JSONResponse(ReadinessResponse(status="ready").model_dump()) - return JSONResponse( - ReadinessResponse(status="not_ready").model_dump(), - status_code=503, + backend_ready = await entry.backend.ready() + status = "ready" if backend_ready else "not_ready" + status_code = 200 if backend_ready else 503 + if entry.database is None: + # Without a configured database the response keeps its old shape. + readiness = SimulationEntrypointReadinessResponse(status=status) + return JSONResponse( + readiness.model_dump(mode="json", exclude={"simulation_database"}), + status_code=status_code, + ) + readiness = SimulationEntrypointReadinessResponse( + status=status, + simulation_database=await _simulation_database_readiness(entry.database), ) + return JSONResponse(readiness.model_dump(mode="json"), status_code=status_code) @app.post( "/ping", @@ -515,9 +558,15 @@ def create_app( backend: SimulationBackend | None = None, auth_dependency: AuthenticationDependency | None = None, comparison_backend: ComparisonBackend | None = None, + simulation_database: SimulationDatabaseConnection | None = None, ) -> FastAPI: """Build the app with injectable auth/backend seams for hermetic tests.""" runtime_settings = settings or Settings.from_env() + runtime_database = simulation_database + if runtime_database is None and runtime_settings.simulation_database_url: + runtime_database = SimulationDatabase.from_url( + runtime_settings.simulation_database_url + ) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -527,6 +576,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: yield finally: await runtime_backend.close() + if runtime_database is not None: + runtime_database.dispose() runtime.shutdown() app = FastAPI( @@ -565,6 +616,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: runtime=runtime, backend=runtime_backend, comparison=runtime_comparison, + database=runtime_database, ) _install_request_context(app, runtime_settings) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 8385097e9..5d23e8c54 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -92,6 +92,10 @@ class Settings: stage12_database_url: str = "" stage12_artifact_bucket: str = "" stage12_retention_days: int = REQUIRED_STAGE12_RETENTION_DAYS + # The runtime role's URL for the Simulation Entrypoint's own schema. Empty + # outside deployed environments; nothing on the old-gateway or Stage 12 + # paths reads it. + simulation_database_url: str = "" @classmethod def from_env(cls) -> Settings: @@ -142,6 +146,7 @@ def from_env(cls) -> Settings: stage12_database_url=os.getenv("STAGE12_DATABASE_URL", ""), stage12_artifact_bucket=os.getenv("STAGE12_ARTIFACT_BUCKET", ""), stage12_retention_days=int(os.getenv("STAGE12_RETENTION_DAYS", "30")), + simulation_database_url=os.getenv("SIMULATION_DATABASE_URL", ""), ) @property @@ -170,6 +175,7 @@ def validate(self) -> None: self._validate_caller_auth() self._validate_old_gateway() self._validate_stage12() + self._validate_simulation_database() def _validate_caller_auth(self) -> None: """Require caller authentication in production, with an HTTPS issuer.""" @@ -267,3 +273,11 @@ def _validate_stage12(self) -> None: ): msg = "STAGE12_DATABASE_URL must use the canonical postgresql:// scheme." raise ConfigurationError(msg) + + def _validate_simulation_database(self) -> None: + """Require the canonical scheme for the schema's runtime URL, when set.""" + if self.simulation_database_url and not self.simulation_database_url.startswith( + "postgresql://" + ): + msg = "SIMULATION_DATABASE_URL must use the canonical postgresql:// scheme." + raise ConfigurationError(msg) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py index 279700705..4898f758f 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py @@ -210,6 +210,21 @@ def _add_response_telemetry( attributes[response_identifier_key] = response_identifier +class SimulationDatabaseConnection(Protocol): + """The Simulation Entrypoint's own database, as the routes use it. + + The real implementation is ``SimulationDatabase``; tests inject fakes. + """ + + async def read_revision(self) -> str | None: + """Return the applied migration revision, or None before the first one.""" + ... + + def dispose(self) -> None: + """Close every pooled connection.""" + ... + + class EntryRuntime: """The settings, telemetry runtime and backends the entry routes share.""" @@ -220,12 +235,14 @@ def __init__( runtime: ObservabilityRuntime, backend: SimulationBackend, comparison: ComparisonBackend | None, + database: SimulationDatabaseConnection | None, ) -> None: """Keep the dependencies that ``create_app`` resolved.""" self.settings = settings self.runtime = runtime self.backend = backend self.comparison = comparison + self.database = database def bind_observability_id(self, request: Request, value: object) -> str | None: """Bind a normalized observability ID to the request and telemetry context.""" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py index d387526df..74aa0f74e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py @@ -115,3 +115,28 @@ class TemporaryStage12ReportResponse(BaseModel): report: ComparisonReportRecord simulations: tuple[ComparisonSimulationRecord, ...] + + +class SimulationDatabaseReadiness(BaseModel): + """The Simulation Entrypoint's own database, as the readiness check found it. + + ``revision`` is the migration revision the database has applied, or null + when it was unreachable or has no migrations yet; ``expected_revision`` is + the newest revision this image knows. Neither changes ``status``: no + old-gateway or Stage 12 route uses this database. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + reachable: bool + revision: str | None + expected_revision: str + + +class SimulationEntrypointReadinessResponse(BaseModel): + """Old-gateway readiness, plus the database revision when one is configured.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + status: Literal["ready", "not_ready"] + simulation_database: SimulationDatabaseReadiness | None = None diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py new file mode 100644 index 000000000..e0ca4f1bd --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -0,0 +1,42 @@ +"""The Simulation Entrypoint's connection to its own ``simulation`` schema.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence import ( + create_simulation_engine, + read_schema_revision, +) + +if TYPE_CHECKING: + from sqlalchemy import Engine + + +class SimulationDatabase: + """Run short, blocking database operations off the event loop.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + @classmethod + def from_url(cls, database_url: str) -> SimulationDatabase: + """Build the runtime engine; no connection opens until first use. + + Raise ValueError, without echoing the URL, when it is malformed. + """ + return cls(create_simulation_engine(database_url)) + + def _read_revision(self) -> str | None: + with self.engine.connect() as connection: + return read_schema_revision(connection) + + async def read_revision(self) -> str | None: + """Return the applied migration revision, or None before the first one.""" + return await asyncio.to_thread(self._read_revision) + + def dispose(self) -> None: + """Close every pooled connection.""" + self.engine.dispose() diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py index 8dbf6809f..23ed45320 100644 --- a/projects/policyengine-simulation-entry/tests/test_config.py +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -297,3 +297,41 @@ def test_validation_reports_the_first_failed_check(overrides, message): ) def test_consistent_configuration_validates(overrides): make_settings(**overrides).validate() + + +def test_the_simulation_database_is_optional(monkeypatch): + from policyengine_simulation_entry.config import Settings + + monkeypatch.delenv("SIMULATION_DATABASE_URL", raising=False) + settings = Settings.from_env() + + assert settings.simulation_database_url == "" + make_settings(simulation_database_url="").validate() + + +def test_the_simulation_database_url_is_read_from_the_environment(monkeypatch): + from policyengine_simulation_entry.config import Settings + + monkeypatch.setenv( + "SIMULATION_DATABASE_URL", + "postgresql://simulation_runtime:secret@db.example:5432/postgres", + ) + + assert Settings.from_env().simulation_database_url == ( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + + +@pytest.mark.parametrize( + "database_url", + [ + "postgres://simulation_runtime:secret@db.example/postgres", + "postgresql+psycopg://simulation_runtime:secret@db.example/postgres", + "mysql://simulation_runtime:secret@db.example/postgres", + ], +) +def test_the_simulation_database_url_must_be_canonical(database_url): + with pytest.raises(ConfigurationError, match="SIMULATION_DATABASE_URL") as error: + make_settings(simulation_database_url=database_url).validate() + + assert "secret" not in str(error.value) diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py index cda498290..68508c896 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py @@ -4,6 +4,7 @@ import os import textwrap +from pathlib import Path import tomllib from test_deployment_assets_support import ( @@ -172,6 +173,28 @@ def test_container_context_excludes_local_environments_and_unrelated_projects(): ) +def test_container_context_includes_every_local_library_the_entrypoint_installs(): + ignore_rules = DOCKERIGNORE.read_text(encoding="utf-8").splitlines() + pyproject = tomllib.loads( + ( + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-entry" + / "pyproject.toml" + ).read_text(encoding="utf-8") + ) + library_paths = [ + Path(source["path"]).name + for source in pyproject["tool"]["uv"]["sources"].values() + if "path" in source + ] + + assert library_paths + for library in library_paths: + assert f"!libs/{library}/" in ignore_rules, library + assert f"!libs/{library}/**" in ignore_rules, library + + def test_production_lock_contains_the_direct_stage12_database_runtime(): lockfile = tomllib.loads( ( diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 060b6bb8c..65e79cf45 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -122,6 +122,22 @@ def test_local_area_breakdowns_are_public(): ]["properties"]["result"]["anyOf"] +def test_readiness_documents_the_simulation_database_revision(): + spec = create_app().openapi() + readiness = spec["paths"]["/ready"]["get"]["responses"]["200"] + schema = readiness["content"]["application/json"]["schema"] + database = spec["components"]["schemas"]["SimulationDatabaseReadiness"] + + assert schema == { + "$ref": "#/components/schemas/SimulationEntrypointReadinessResponse" + } + assert set(database["properties"]) == { + "reachable", + "revision", + "expected_revision", + } + + def test_route_table_is_frozen(): assert methods(cast("OpenAPIDocument", create_app().openapi())) == EXPECTED_ROUTES @@ -161,7 +177,9 @@ def test_normalized_contract_matches_old_gateway(): cloud_run_spec ) == normalized_compatibility_paths(gateway_spec) cloud_run_schemas = deepcopy(cloud_run_spec["components"]["schemas"]) - cloud_run_schemas.pop("ReadinessResponse") + # The entrypoint's readiness response adds its own database's revision. + cloud_run_schemas.pop("SimulationEntrypointReadinessResponse") + cloud_run_schemas.pop("SimulationDatabaseReadiness") cloud_run_schemas.pop("SimulationErrorResponse") assert cloud_run_schemas == gateway_spec["components"]["schemas"] gateway_operation_ids = operation_ids(gateway_spec) diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database.py b/projects/policyengine-simulation-entry/tests/test_simulation_database.py new file mode 100644 index 000000000..95b1defae --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database.py @@ -0,0 +1,184 @@ +"""The Simulation Entrypoint's own database: readiness reporting and lifetime.""" + +from __future__ import annotations + +import asyncio + +import pytest +from conftest import FakeBackend, make_settings +from fastapi.testclient import TestClient +from policyengine_simulation_entry import app as app_module +from policyengine_simulation_entry import simulation_database as database_module +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.simulation_database import SimulationDatabase +from policyengine_simulation_persistence import HEAD_REVISION +from sqlalchemy.exc import OperationalError + +DATABASE_URL = "postgresql://simulation_runtime:secret@db.example:5432/postgres" + + +class FakeSimulationDatabase: + """Report a fixed revision, or fail the way an unreachable database does.""" + + def __init__(self, *, revision=HEAD_REVISION, failure=None, delay_seconds=0): + self.revision = revision + self.failure = failure + self.delay_seconds = delay_seconds + self.disposed = False + + async def read_revision(self): + if self.delay_seconds: + await asyncio.sleep(self.delay_seconds) + if self.failure is not None: + raise self.failure + return self.revision + + def dispose(self): + self.disposed = True + + +def _client(backend, database): + return TestClient( + create_app( + settings=make_settings(simulation_database_url=DATABASE_URL), + backend=backend, + auth_dependency=lambda: None, + simulation_database=database, + ) + ) + + +def test_readiness_reports_the_database_revision_and_the_expected_one(): + backend = FakeBackend() + with _client(backend, FakeSimulationDatabase(revision="0001")) as client: + result = client.get("/ready") + + assert result.status_code == 200 + assert result.json() == { + "status": "ready", + "simulation_database": { + "reachable": True, + "revision": "0001", + "expected_revision": HEAD_REVISION, + }, + } + + +def test_readiness_reports_a_database_that_has_no_migrations_yet(): + with _client(FakeBackend(), FakeSimulationDatabase(revision=None)) as client: + database = client.get("/ready").json()["simulation_database"] + + assert database == { + "reachable": True, + "revision": None, + "expected_revision": HEAD_REVISION, + } + + +def test_an_unreachable_database_does_not_change_old_gateway_readiness(caplog): + failure = OperationalError("SELECT 1", {}, Exception("password=secret")) + with _client(FakeBackend(), FakeSimulationDatabase(failure=failure)) as client: + result = client.get("/ready") + + assert result.status_code == 200 + assert result.json() == { + "status": "ready", + "simulation_database": { + "reachable": False, + "revision": None, + "expected_revision": HEAD_REVISION, + }, + } + assert "secret" not in caplog.text + + +def test_readiness_waits_only_briefly_for_the_database(monkeypatch): + monkeypatch.setattr( + app_module, "SIMULATION_DATABASE_READINESS_TIMEOUT_SECONDS", 0.01 + ) + slow_database = FakeSimulationDatabase(delay_seconds=1) + with _client(FakeBackend(), slow_database) as client: + database = client.get("/ready").json()["simulation_database"] + + assert database["reachable"] is False + + +def test_readiness_still_follows_the_old_gateway(): + backend = FakeBackend() + backend.is_ready = False + with _client(backend, FakeSimulationDatabase()) as client: + result = client.get("/ready") + + assert result.status_code == 503 + assert result.json()["status"] == "not_ready" + assert result.json()["simulation_database"]["reachable"] is True + + +def test_the_database_is_released_at_shutdown(): + database = FakeSimulationDatabase() + with _client(FakeBackend(), database): + assert database.disposed is False + + assert database.disposed is True + + +def test_settings_build_the_database_only_when_it_is_configured(monkeypatch): + built_urls = [] + + def build(database_url): + built_urls.append(database_url) + return FakeSimulationDatabase() + + monkeypatch.setattr(SimulationDatabase, "from_url", staticmethod(build)) + + create_app(settings=make_settings(), backend=FakeBackend()) + assert built_urls == [] + + create_app( + settings=make_settings(simulation_database_url=DATABASE_URL), + backend=FakeBackend(), + ) + assert built_urls == [DATABASE_URL] + + +def test_the_database_reads_the_revision_without_blocking_the_event_loop( + monkeypatch, +): + connections = [] + + class FakeConnection: + def __enter__(self): + connections.append(self) + return self + + def __exit__(self, *_): + return False + + class FakeEngine: + disposed = False + + def connect(self): + return FakeConnection() + + def dispose(self): + self.disposed = True + + monkeypatch.setattr( + database_module, + "read_schema_revision", + lambda connection: "0001" if connection in connections else None, + ) + engine = FakeEngine() + database = SimulationDatabase(engine) + + assert asyncio.run(database.read_revision()) == "0001" + database.dispose() + assert engine.disposed is True + + +@pytest.mark.parametrize("database_url", ["", "postgres://user:secret@db/postgres"]) +def test_a_malformed_database_url_never_builds_an_engine(database_url): + with pytest.raises(ValueError, match="Simulation database URL") as error: + SimulationDatabase.from_url(database_url) + + assert "secret" not in str(error.value) diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index c3bfa7d53..b472cb3ed 100644 --- a/projects/policyengine-simulation-entry/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -901,6 +901,7 @@ dependencies = [ { name = "policyengine-observability", extra = ["fastapi", "google", "httpx", "otlp-grpc"] }, { name = "policyengine-simulation-contract" }, { name = "policyengine-simulation-observability" }, + { name = "policyengine-simulation-persistence" }, { name = "policyengine-stage12-persistence" }, { name = "pyjwt" }, { name = "uvicorn" }, @@ -928,6 +929,7 @@ requires-dist = [ { name = "policyengine-observability", extras = ["fastapi", "google", "httpx", "otlp-grpc"], specifier = ">=3.0.4,<4" }, { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, + { name = "policyengine-simulation-persistence", editable = "../../libs/policyengine-simulation-persistence" }, { name = "policyengine-stage12-persistence", editable = "../../libs/policyengine-stage12-persistence" }, { name = "pyjwt", specifier = ">=2.10.1,<3" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, @@ -963,6 +965,28 @@ requires-dist = [ ] provides-extras = ["test", "build"] +[[package]] +name = "policyengine-simulation-persistence" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-persistence" } +dependencies = [ + { name = "psycopg", extra = ["binary"] }, + { name = "sqlalchemy" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.16,<2" }, + { name = "alembic", marker = "extra == 'test'", specifier = ">=1.16,<2" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, + { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0.2" }, + { name = "sqlalchemy", specifier = ">=2,<3" }, +] +provides-extras = ["migrations", "test", "build"] + [[package]] name = "policyengine-stage12-persistence" version = "0.1.0" diff --git a/rules/registry.toml b/rules/registry.toml index 07bee73c6..f356c0142 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -612,9 +612,9 @@ invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.container-context-minimal" category = "deployment" -statement = "Container builds exclude local environments and unrelated projects, and the entrypoint container builds for the requested target platform." +statement = "Container builds exclude local environments and unrelated projects but include every local library the entrypoint installs, and the entrypoint container builds for the requested target platform." rationale = "docs/operations" -scope = ["projects/policyengine-simulation-entry/Dockerfile"] +scope = ["projects/policyengine-simulation-entry/Dockerfile", "projects/policyengine-simulation-entry/Dockerfile.dockerignore"] tier = "mechanical" status = "enforced" autofix = false @@ -623,6 +623,7 @@ kind = "pytest" project = "projects/policyengine-simulation-entry" nodes = [ "tests/test_deployment_assets_traffic.py::test_container_context_excludes_local_environments_and_unrelated_projects", + "tests/test_deployment_assets_traffic.py::test_container_context_includes_every_local_library_the_entrypoint_installs", "tests/test_deployment_assets_cloud_run.py::test_entrypoint_container_uses_requested_target_platform", ] [[rule.ci]] From 5b3d70b6cf9727ac638f4b61edad1bac2fce6b8a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:17:21 +0400 Subject: [PATCH 027/124] Migrate the simulation schema before each entrypoint deploy Each deployment (the main deploy and the Stage 12 deploy both use the reusable workflow) now runs a migrate_simulation_database job after prepare. It installs the persistence library's locked environment with the migrations extra and runs .github/scripts/migrate-simulation-database.sh, which applies the chain with alembic upgrade head and then requires the database to be at the head. The job is the only one that receives SIMULATION_MIGRATION_DATABASE_URL (a GitHub environment secret in beta and prod), in its migration step's env alone. deploy_entrypoint needs the job and runs only when it succeeded. The deploy script now requires SIMULATION_DATABASE_URL_SECRET_NAME (a GitHub environment variable in beta and prod) and mounts that Secret Manager secret's newest enabled version as SIMULATION_DATABASE_URL on every deploy, whatever the Stage 12 setting. The candidate smoke check sets REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION=true and then requires /ready to report the database reachable and at the image's revision; the stable and rollback checks do not, because an older image may face a newer schema. The wiring is registered as sim.persistence.migrate-before-entrypoint. Co-Authored-By: Claude Opus 5.5 --- .../cloud-run-simulation-entry-smoke.sh | 28 +- .../deploy-cloud-run-simulation-entry.sh | 6 + .../scripts/migrate-simulation-database.sh | 19 ++ .../workflows/simulation-deploy.reusable.yml | 34 +- AGENTS.md | 2 +- docs/generated/rules.md | 10 +- .../README.md | 11 + .../DEPLOYMENT.md | 25 ++ .../tests/test_deployment_assets.py | 5 +- .../tests/test_deployment_assets_cloud_run.py | 12 +- .../tests/test_deployment_assets_support.py | 3 +- .../test_simulation_database_deployment.py | 295 ++++++++++++++++++ rules/registry.toml | 23 ++ 13 files changed, 459 insertions(+), 14 deletions(-) create mode 100755 .github/scripts/migrate-simulation-database.sh create mode 100644 projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py diff --git a/.github/scripts/cloud-run-simulation-entry-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh index 84c0726da..ee36a32e3 100755 --- a/.github/scripts/cloud-run-simulation-entry-smoke.sh +++ b/.github/scripts/cloud-run-simulation-entry-smoke.sh @@ -25,6 +25,12 @@ previous_revision="${3:-}" # and model. # This is the identity the gateway reports from the routing registry; the # numerical release sentinel checks what the worker computes. +# REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION=true (candidate checks only): +# /ready must report the entrypoint's own database reachable and at the +# migration revision the image expects, which shows that the runtime secret +# reached the revision and that the migration job ran. Stable and rollback +# checks leave it unset, because an older image may face a newer schema. +require_database_revision="${REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION:-}" expected_identity="${EXPECTED_IDENTITY:-none}" expected_policyengine_version="${EXPECTED_POLICYENGINE_VERSION:-}" expected_app_name="${EXPECTED_APP_NAME:-}" @@ -39,6 +45,14 @@ case "${expected_identity}" in exit 2 ;; esac +case "${require_database_revision}" in + "" | true) ;; + *) + printf 'REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION must be true or empty, not %s\n' \ + "${require_database_revision}" >&2 + exit 2 + ;; +esac case "${expected_latest}" in "" | true) ;; *) @@ -131,8 +145,18 @@ while true; do attempt=$((attempt + 1)) done -curl --fail --silent --show-error "${base_url}/ready" | - jq -e '.status == "ready"' >/dev/null +ready_body="$(curl --fail --silent --show-error "${base_url}/ready")" +jq -e '.status == "ready"' <<<"${ready_body}" >/dev/null +if [ "${require_database_revision}" = "true" ] && + ! jq -e ' + .simulation_database.reachable == true + and (.simulation_database.revision | type == "string") + and .simulation_database.revision == .simulation_database.expected_revision + ' <<<"${ready_body}" >/dev/null; then + printf '%s: the simulation database is not at the expected revision: %s\n' \ + "${base_url}" "$(jq -c '.simulation_database' <<<"${ready_body}")" >&2 + exit 1 +fi curl --fail --silent --show-error "${base_url}/versions" --output "${versions_body}" jq -e 'type == "object"' "${versions_body}" >/dev/null diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 4320514ff..d0c042930 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -77,6 +77,7 @@ required_variables=( OLD_GATEWAY_AUTH_AUDIENCE_VALUE OLD_GATEWAY_AUTH_CLIENT_ID_VALUE OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME + SIMULATION_DATABASE_URL_SECRET_NAME OBSERVABILITY_SERVICE_NAMESPACE OBSERVABILITY_TRACE_PROJECT_ID OTEL_EXPORTER_OTLP_ENDPOINT @@ -115,10 +116,15 @@ export SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE="${SIMULATION_ENTRYPOINT_ [[ "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "0" || "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "1" ]] \ || fail "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE must be 0 or 1" +# The runtime role's URL for the entrypoint's own simulation schema. The +# migration job, which uses a separate migrator URL, runs before this deploy. runtime_secrets=( "OLD_GATEWAY_AUTH_CLIENT_SECRET=$( resolve_secret_reference "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}" )" + "SIMULATION_DATABASE_URL=$( + resolve_secret_reference "${SIMULATION_DATABASE_URL_SECRET_NAME}" + )" ) if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then diff --git a/.github/scripts/migrate-simulation-database.sh b/.github/scripts/migrate-simulation-database.sh new file mode 100755 index 000000000..0572ea43f --- /dev/null +++ b/.github/scripts/migrate-simulation-database.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Apply the simulation schema's Alembic chain, then require the database to be +# at the chain's head. Run from libs/policyengine-simulation-persistence, whose +# locked environment (with the migrations extra) provides Alembic. +# migrations/env.py reads the migrator URL from SIMULATION_MIGRATION_DATABASE_URL; +# this script never prints it. + +set -euo pipefail +set +x + +if [[ -z "${SIMULATION_MIGRATION_DATABASE_URL:-}" ]]; then + printf 'SIMULATION_MIGRATION_DATABASE_URL must name the simulation database\n' >&2 + exit 1 +fi + +alembic_config="${SIMULATION_ALEMBIC_CONFIG:-../../migrations/alembic.ini}" + +uv run --frozen --extra migrations alembic -c "${alembic_config}" upgrade head +uv run --frozen --extra migrations alembic -c "${alembic_config}" current --check-heads diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 9de0a3eff..00e0e5d70 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -153,10 +153,37 @@ jobs: GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" + migrate_simulation_database: + # Applies the simulation schema's Alembic chain before the entrypoint + # candidate deploys. The previous revision keeps serving meanwhile, so + # every migration must work with the release before it. + name: Migrate the simulation database schema + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install migration dependencies + working-directory: libs/policyengine-simulation-persistence + run: uv sync --frozen --extra migrations + + - name: Apply simulation schema migrations + working-directory: libs/policyengine-simulation-persistence + env: + SIMULATION_MIGRATION_DATABASE_URL: ${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }} + run: ../../.github/scripts/migrate-simulation-database.sh + deploy_entrypoint: name: Deploy and unit test Cloud Run entrypoint - if: ${{ always() && needs.prepare.result == 'success' && ((inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'success') || (!inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'skipped')) }} - needs: [prepare, configure_stage12_v2] + if: ${{ always() && needs.prepare.result == 'success' && needs.migrate_simulation_database.result == 'success' && ((inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'success') || (!inputs.deploy_stage12_v2 && needs.configure_stage12_v2.result == 'skipped')) }} + needs: [prepare, configure_stage12_v2, migrate_simulation_database] runs-on: ubuntu-latest environment: ${{ inputs.release_environment }} permissions: @@ -237,6 +264,7 @@ jobs: OLD_GATEWAY_AUTH_AUDIENCE_VALUE: ${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }} OLD_GATEWAY_AUTH_CLIENT_ID_VALUE: ${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }} OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME: ${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }} + SIMULATION_DATABASE_URL_SECRET_NAME: ${{ vars.SIMULATION_DATABASE_URL_SECRET_NAME }} STAGE12_ENABLED_VALUE: ${{ vars.STAGE12_ENABLED }} STAGE12_ARTIFACT_BUCKET_VALUE: ${{ vars.STAGE12_ARTIFACT_BUCKET }} STAGE12_DATABASE_URL_SECRET_NAME: ${{ vars.STAGE12_DATABASE_URL_SECRET_NAME }} @@ -744,6 +772,8 @@ jobs: EXPECTED_UK_DATASET_SHA256: ${{ inputs.deploy_existing_stack && needs.prepare.outputs.uk_dataset_sha256 || '' }} # Latest must route to this release only when it should advance it. EXPECTED_LATEST: ${{ inputs.deploy_existing_stack && needs.update_routing.outputs.advances_latest == 'true' && 'true' || '' }} + # The candidate reaches its own database at the migrated revision. + REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION: "true" run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ needs.deploy_entrypoint.outputs.candidate_url }}" "${{ needs.deploy_entrypoint.outputs.revision }}" - name: Download the numerical release sentinel diff --git a/AGENTS.md b/AGENTS.md index 4c15161c5..7c410cfdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Simulation persistence -- Checked: `sim.persistence.executor-independent`, `sim.persistence.migration-cycle`, `sim.persistence.no-modal-database-credentials`, `sim.persistence.one-migration-chain` +- Checked: `sim.persistence.executor-independent`, `sim.persistence.migrate-before-entrypoint`, `sim.persistence.migration-cycle`, `sim.persistence.no-modal-database-credentials`, `sim.persistence.one-migration-chain` ### Deployment pipeline diff --git a/docs/generated/rules.md b/docs/generated/rules.md index c50c886f4..55aaf3b91 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 56 | 2 | 0 | 14 | 72 | +| mechanical | 57 | 2 | 0 | 14 | 73 | | metric | 1 | 0 | 0 | 0 | 1 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 59 of 73 rules. +**Blocking:** 60 of 74 rules. ## Gaps @@ -54,6 +54,7 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.deploy.smoke-tests-strict` - `sim.deploy.traffic-changes-exact` - `sim.deploy.workflow-shell-safety` +- `sim.persistence.migrate-before-entrypoint` - `sim.persistence.migration-cycle` - `sim.process.rules-registry` - `sim.stage12.deployment-scope` @@ -140,6 +141,7 @@ flowchart LR | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | | --- | --- | --- | --- | --- | --- | --- | --- | | `sim.persistence.executor-independent` | The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | +| `sim.persistence.migrate-before-entrypoint` | Each deployment applies the simulation chain in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py` | `pr.yml:test` | **unproven** | | `sim.persistence.migration-cycle` | On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py` | `pr.yml:simulation-database` | **unproven** | | `sim.persistence.no-modal-database-credentials` | No Modal secret, Modal deployment or Modal application receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL; until the comparison path is removed, its own database secret is the only database credential Modal holds. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | | `sim.persistence.one-migration-chain` | The simulation schema changes only through the one Alembic chain at migrations/, configured by migrations/alembic.ini and versioned in simulation.alembic_version; no other Alembic configuration, environment or revision directory exists. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | @@ -149,6 +151,8 @@ flowchart LR r_sim_persistence_executor_independent("sim.persistence.executor-independent (enforced)") e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py["pytest libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py"] j_CI_pr_yml_test["CI pr.yml:test"] + r_sim_persistence_migrate_before_entrypoint("sim.persistence.migrate-before-entrypoint (enforced)") + e_pytest_projects_policyengine_simulation_entry_tests_test_simulation_database_deployment_py["pytest projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py"] r_sim_persistence_migration_cycle("sim.persistence.migration-cycle (enforced)") e_pytest_libs_policyengine_simulation_persistence_tests_test_migration_cycle_py["pytest libs/policyengine-simulation-persistence/tests/test_migration_cycle.py"] j_CI_pr_yml_simulation_database["CI pr.yml:simulation-database"] @@ -156,6 +160,8 @@ flowchart LR r_sim_persistence_one_migration_chain("sim.persistence.one-migration-chain (enforced)") r_sim_persistence_executor_independent --> e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py --> j_CI_pr_yml_test + r_sim_persistence_migrate_before_entrypoint --> e_pytest_projects_policyengine_simulation_entry_tests_test_simulation_database_deployment_py + e_pytest_projects_policyengine_simulation_entry_tests_test_simulation_database_deployment_py --> j_CI_pr_yml_test r_sim_persistence_migration_cycle --> e_pytest_libs_policyengine_simulation_persistence_tests_test_migration_cycle_py e_pytest_libs_policyengine_simulation_persistence_tests_test_migration_cycle_py --> j_CI_pr_yml_simulation_database r_sim_persistence_no_modal_database_credentials --> e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 35fdcb0a9..d74612eb8 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -46,6 +46,17 @@ by it: the environment creates the schema only when it is missing, because PostgreSQL checks `CREATE` on the database even for `CREATE SCHEMA IF NOT EXISTS`. +## Deployment + +On every deployment, the migration job (`migrate_simulation_database` in +`.github/workflows/simulation-deploy.reusable.yml`) runs +`alembic upgrade head` before the Simulation Entrypoint's candidate revision is +deployed (`sim.persistence.migrate-before-entrypoint`). It is the only job that +receives `SIMULATION_MIGRATION_DATABASE_URL`. If it fails, the entrypoint is not +deployed. The Simulation Entrypoint's readiness check (`GET /ready`) reports +the database's revision next to `HEAD_REVISION`, the revision its image +expects, and the candidate check requires the two to match. + ## Connections `create_simulation_engine` builds the runtime engine from the canonical diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 9783ef1cc..1716be9a7 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -22,6 +22,31 @@ Credential values belong in the cloud secret store. They must not be committed to the repository or stored as plain GitHub variables. The deployment workflow receives only the configuration needed to reference those protected resources. +## Simulation database + +The Simulation Entrypoint owns the `simulation` schema in the Postgres database +that holds the Public API's v2 tables. Before the entrypoint candidate deploys +in each environment, the `migrate_simulation_database` job applies the +schema's Alembic chain (`migrations/`) with the migrator role. It is the only +job that receives `SIMULATION_MIGRATION_DATABASE_URL`, a GitHub environment +secret in `beta` and `prod`, passed in that step's `env:` alone. If the +migration fails, the entrypoint is not deployed. + +The entrypoint itself connects as the runtime role. The deploy step resolves +the Secret Manager secret named by the `SIMULATION_DATABASE_URL_SECRET_NAME` +environment variable to its newest enabled version and mounts that version as +`SIMULATION_DATABASE_URL`. Only the entrypoint's runtime service account may +read it. Modal never receives either URL. + +`GET /ready` reports the database's applied revision next to the revision the +image expects, under `simulation_database`. The database does not change the +readiness status, because no old-gateway or Stage 12 route uses it. The +candidate smoke check requires the two revisions to match, which shows that the +runtime secret reached the revision and the migration ran; the stable and +rollback checks do not, because an older image may run against a newer schema. +The ownership rules and roles are described in +`libs/policyengine-simulation-persistence/README.md`. + ## Candidate qualification For each eligible revision, the workflow first deploys `beta` diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index a47600cbc..804fae52b 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -210,8 +210,9 @@ def _assert_beta_then_prod_release( def _assert_deployment_environment_settings(reusable_workflow: str) -> None: assert ( - # Sentinel, restore and final routing acknowledgement use secrets too. - reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 15 + # Sentinel, restore, final routing acknowledgement and the simulation + # database migration use secrets too. + reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 16 ) assert "APP_ENVIRONMENT: ${{ inputs.deployment_environment }}" in reusable_workflow assert "STAGE12_ENABLED_VALUE: ${{ vars.STAGE12_ENABLED }}" in reusable_workflow diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py index 1052bfab5..6c88a9cbb 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py @@ -39,7 +39,7 @@ def test_cloud_run_deployment_does_not_interpolate_github_values_in_shell(): assert ":latest" not in deployment_script -def _cloud_run_deploy_environment(tmp_path: Path) -> dict[str, str]: +def cloud_run_deploy_environment(tmp_path: Path) -> dict[str, str]: """Return a complete deployment environment backed by a fake gcloud.""" fake_bin = tmp_path / "bin" fake_bin.mkdir() @@ -114,11 +114,14 @@ def _cloud_run_deploy_environment(tmp_path: Path) -> dict[str, str]: "STAGE12_DATABASE_URL_SECRET_NAME": "stage12-staging-database-url", "MODAL_TOKEN_ID_SECRET_NAME": "modal-token-id", "MODAL_TOKEN_SECRET_SECRET_NAME": "modal-token-secret", + "SIMULATION_DATABASE_URL_SECRET_NAME": ( + "simulation-staging-runtime-database-url" + ), } def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_path): - command_environment = _cloud_run_deploy_environment(tmp_path) + command_environment = cloud_run_deploy_environment(tmp_path) arguments_capture = Path(command_environment["GCLOUD_ARGUMENTS_CAPTURE"]) env_capture = Path(command_environment["GCLOUD_ENV_FILE_CAPTURE"]) @@ -152,6 +155,7 @@ def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_p secrets_argument = arguments[arguments.index("--set-secrets") + 1] assert secrets_argument == ( "OLD_GATEWAY_AUTH_CLIENT_SECRET=old-client-secret:3," + "SIMULATION_DATABASE_URL=simulation-staging-runtime-database-url:3," "MODAL_TOKEN_ID=modal-token-id:3," "MODAL_TOKEN_SECRET=modal-token-secret:3," "STAGE12_DATABASE_URL=stage12-staging-database-url:3" @@ -169,7 +173,7 @@ def test_cloud_run_deployment_defaults_permission_enforcement_to_log_only( configured, expected, ): - command_environment = _cloud_run_deploy_environment(tmp_path) + command_environment = cloud_run_deploy_environment(tmp_path) if configured is not None: command_environment["SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE"] = ( configured @@ -192,7 +196,7 @@ def test_cloud_run_deployment_rejects_invalid_permission_enforcement( tmp_path, configured, ): - command_environment = _cloud_run_deploy_environment(tmp_path) + command_environment = cloud_run_deploy_environment(tmp_path) command_environment["SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE"] = configured result = subprocess.run( diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py index ae004c799..7b4748349 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py @@ -128,7 +128,8 @@ def write_fake_smoke_curl(tmp_path: Path, revisions: list[str]) -> tuple[Path, P printf '{"status":"healthy"}' > "${body_file}" ;; */ready) - printf '{"status":"ready"}' + ready_body='{"status":"ready"}' + printf '%s' "${FAKE_CURL_READY:-${ready_body}}" ;; */versions) versions='{}' diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py new file mode 100644 index 000000000..043367217 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py @@ -0,0 +1,295 @@ +"""Deployment wiring for the Simulation Entrypoint's own database. + +The migration job applies the chain before the entrypoint candidate deploys, +with the migrator URL in that job alone; the entrypoint receives the runtime +URL as a pinned Cloud Run secret; and the candidate smoke requires the +database to be at the image's revision. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest +from test_deployment_assets_cloud_run import cloud_run_deploy_environment +from test_deployment_assets_support import ( + CLOUD_RUN_DEPLOY_SCRIPT, + REUSABLE_DEPLOY_WORKFLOW, + SMOKE_SCRIPT, + evaluate_github_expression, + smoke_test_env, + workflow_jobs, + write_fake_smoke_curl, +) +from workflow_script_support import WORKFLOWS_DIR, FakeCommands, run_script + +MIGRATION_SCRIPT = "migrate-simulation-database.sh" +MIGRATION_JOB = "migrate_simulation_database" +MIGRATION_SECRET = "SIMULATION_MIGRATION_DATABASE_URL" +FAKE_MIGRATION_URL = "postgresql://simulation_migrator:secret@db.example/postgres" + + +@pytest.fixture +def fakes(tmp_path: Path) -> FakeCommands: + return FakeCommands.create(tmp_path) + + +# --- The migration job ---------------------------------------------------- + + +def test_the_migration_job_runs_before_the_entrypoint_deploys(): + jobs = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW) + migration = jobs[MIGRATION_JOB] + deploy_entrypoint = jobs["deploy_entrypoint"] + + assert migration["needs"] == "prepare" + assert migration["environment"] == "${{ inputs.release_environment }}" + assert MIGRATION_JOB in deploy_entrypoint["needs"] + context = { + "inputs": {"deploy_stage12_v2": True}, + "needs": { + "prepare": {"result": "success"}, + "configure_stage12_v2": {"result": "success"}, + MIGRATION_JOB: {"result": "success"}, + }, + } + assert evaluate_github_expression(deploy_entrypoint["if"], context, job_if=True) + for failed_result in ("failure", "skipped", "cancelled"): + context["needs"][MIGRATION_JOB]["result"] = failed_result + assert not evaluate_github_expression( + deploy_entrypoint["if"], context, job_if=True + ) + + +def test_the_migration_job_applies_the_chain_with_the_migrator_url_alone(): + migration = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)[MIGRATION_JOB] + steps = {step.get("name"): step for step in migration["steps"]} + install = steps["Install migration dependencies"] + apply_step = steps["Apply simulation schema migrations"] + + assert install["working-directory"] == "libs/policyengine-simulation-persistence" + assert install["run"] == "uv sync --frozen --extra migrations" + assert apply_step["working-directory"] == "libs/policyengine-simulation-persistence" + assert apply_step["run"] == f"../../.github/scripts/{MIGRATION_SCRIPT}" + assert apply_step["env"] == { + MIGRATION_SECRET: "${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }}" + } + assert "env" not in migration + assert "permissions" not in migration + + +def test_only_the_migration_step_receives_the_migrator_url(): + carriers = [] + for workflow in sorted(WORKFLOWS_DIR.glob("*.yml")): + for job_name, job in workflow_jobs(workflow).items(): + if MIGRATION_SECRET in json.dumps(job.get("env", {})): + carriers.append((workflow.name, job_name, "job env")) + carriers.extend( + (workflow.name, job_name, step.get("name")) + for step in job.get("steps", []) + if MIGRATION_SECRET in json.dumps(step) + ) + + assert carriers == [ + ( + "simulation-deploy.reusable.yml", + MIGRATION_JOB, + "Apply simulation schema migrations", + ) + ] + + +def test_the_migration_script_upgrades_and_then_requires_the_head(fakes): + fakes.add("uv") + + result = run_script( + MIGRATION_SCRIPT, + env=fakes.env(SIMULATION_MIGRATION_DATABASE_URL=FAKE_MIGRATION_URL), + ) + + assert result.returncode == 0, result.stderr + config = "../../migrations/alembic.ini" + assert fakes.calls("uv") == [ + ["uv", "run", "--frozen", "--extra", "migrations", "alembic", "-c", config, + "upgrade", "head"], + ["uv", "run", "--frozen", "--extra", "migrations", "alembic", "-c", config, + "current", "--check-heads"], + ] # fmt: skip + assert "secret" not in result.stdout + result.stderr + + +def test_the_migration_script_refuses_to_run_without_the_migrator_url(fakes): + fakes.add("uv") + + result = run_script( + MIGRATION_SCRIPT, + env=fakes.env(SIMULATION_MIGRATION_DATABASE_URL=""), + ) + + assert result.returncode != 0 + assert MIGRATION_SECRET in result.stderr + assert fakes.calls("uv") == [] + + +def test_a_failed_upgrade_fails_the_job_before_the_head_check(fakes): + fakes.add("uv", 'if [[ " $* " == *" upgrade "* ]]; then exit 3; fi\n') + + result = run_script( + MIGRATION_SCRIPT, + env=fakes.env(SIMULATION_MIGRATION_DATABASE_URL=FAKE_MIGRATION_URL), + ) + + assert result.returncode == 3 + assert len(fakes.calls("uv")) == 1 + + +# --- The entrypoint's runtime URL ----------------------------------------- + + +def _run_deploy(tmp_path, **overrides): + environment = cloud_run_deploy_environment(tmp_path) | overrides + result = subprocess.run( + [CLOUD_RUN_DEPLOY_SCRIPT], + check=False, + capture_output=True, + text=True, + env=environment, + ) + return result, environment + + +def _secrets_argument(environment) -> str: + arguments = ( + Path(environment["GCLOUD_ARGUMENTS_CAPTURE"]) + .read_text(encoding="utf-8") + .splitlines() + ) + return arguments[arguments.index("--set-secrets") + 1] + + +@pytest.mark.parametrize("deploy_stage12", ["true", "false"]) +def test_the_entrypoint_receives_the_runtime_url_as_a_pinned_secret( + tmp_path, deploy_stage12 +): + result, environment = _run_deploy(tmp_path, DEPLOY_STAGE12_V2=deploy_stage12) + + assert result.returncode == 0, result.stderr + assert ( + "SIMULATION_DATABASE_URL=simulation-staging-runtime-database-url:3" + in _secrets_argument(environment).split(",") + ) + runtime_environment = json.loads( + Path(environment["GCLOUD_ENV_FILE_CAPTURE"]).read_text(encoding="utf-8") + ) + assert "SIMULATION_DATABASE_URL" not in runtime_environment + assert MIGRATION_SECRET not in runtime_environment + + +@pytest.mark.parametrize( + "secret_name", + ["", "not a secret name", "projects/x/secrets/name/versions/1"], +) +def test_the_entrypoint_deploy_requires_a_valid_runtime_url_secret( + tmp_path, secret_name +): + result, _ = _run_deploy(tmp_path, SIMULATION_DATABASE_URL_SECRET_NAME=secret_name) + + assert result.returncode != 0 + assert "SIMULATION_DATABASE_URL_SECRET_NAME" in result.stderr or ( + "Secret Manager reference" in result.stderr + ) + + +def test_the_deploy_step_passes_the_runtime_secret_name(): + deploy_entrypoint = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["deploy_entrypoint"] + deploy_step = next( + step + for step in deploy_entrypoint["steps"] + if step.get("name") == "Deploy Cloud Run entrypoint candidate" + ) + + assert deploy_step["env"]["SIMULATION_DATABASE_URL_SECRET_NAME"] == ( + "${{ vars.SIMULATION_DATABASE_URL_SECRET_NAME }}" + ) + + +# --- The candidate smoke -------------------------------------------------- + + +def test_the_candidate_smoke_requires_the_current_database_revision(): + integration = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["integration"] + smoke = next( + step + for step in integration["steps"] + if step.get("name") == "Verify the complete deployed request path" + ) + + assert smoke["env"]["REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION"] == "true" + + +def _ready_body(revision, expected_revision="0001"): + return json.dumps( + { + "status": "ready", + "simulation_database": { + "reachable": revision is not None, + "revision": revision, + "expected_revision": expected_revision, + }, + } + ) + + +def _run_smoke(tmp_path, ready_body, *, require): + state_file, revisions_file = write_fake_smoke_curl(tmp_path, ["revision-1"]) + environment = smoke_test_env(tmp_path, state_file, revisions_file) | { + "FAKE_CURL_READY": ready_body, + } + if require is not None: + environment["REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION"] = require + return subprocess.run( + ["bash", SMOKE_SCRIPT, "https://candidate.example.test", "revision-1"], + check=False, + capture_output=True, + text=True, + env=environment, + ) + + +def test_the_smoke_accepts_a_database_at_the_expected_revision(tmp_path): + result = _run_smoke(tmp_path, _ready_body("0001"), require="true") + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "ready_body", + [ + _ready_body(None), + _ready_body("0001", expected_revision="0002"), + json.dumps({"status": "ready"}), + ], + ids=["unreachable", "behind", "unconfigured"], +) +def test_the_smoke_rejects_a_database_not_at_the_expected_revision( + tmp_path, ready_body +): + result = _run_smoke(tmp_path, ready_body, require="true") + + assert result.returncode == 1 + assert "simulation database" in result.stderr + + +def test_stable_and_rollback_smokes_ignore_the_database_revision(tmp_path): + result = _run_smoke(tmp_path, _ready_body(None), require=None) + + assert result.returncode == 0, result.stderr + + +def test_the_smoke_refuses_an_unknown_database_requirement(tmp_path): + result = _run_smoke(tmp_path, _ready_body("0001"), require="yes") + + assert result.returncode == 2 + assert "REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION" in result.stderr diff --git a/rules/registry.toml b/rules/registry.toml index f356c0142..998c35ed9 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -230,6 +230,29 @@ good_fixture = "rules/fixtures/sim.persistence.no-modal-database-credentials/goo project = "libs/policyengine-simulation-persistence" test = "tests/test_ownership_boundaries.py::test_rule_proof_no_modal_database_credentials" +[[rule]] +id = "sim.persistence.migrate-before-entrypoint" +category = "persistence" +statement = "Each deployment applies the simulation chain in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision." +rationale = "libs/policyengine-simulation-persistence/README.md#deployment" +scope = [".github/workflows/simulation-deploy.reusable.yml", ".github/scripts/migrate-simulation-database.sh", ".github/scripts/deploy-cloud-run-simulation-entry.sh", ".github/scripts/cloud-run-simulation-entry-smoke.sh"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "projects/policyengine-simulation-entry" +nodes = [ + "tests/test_simulation_database_deployment.py::test_the_migration_job_runs_before_the_entrypoint_deploys", + "tests/test_simulation_database_deployment.py::test_only_the_migration_step_receives_the_migrator_url", + "tests/test_simulation_database_deployment.py::test_the_entrypoint_receives_the_runtime_url_as_a_pinned_secret", + "tests/test_simulation_database_deployment.py::test_the_candidate_smoke_requires_the_current_database_revision", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run --frozen pytest tests/" + # --- Stage 12 persistence -------------------------------------------------- [[rule]] From c45989054a0c86f30afa8b24396c6bf35de423ea Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:35:40 +0400 Subject: [PATCH 028/124] Stop the entrypoint deploy when any runtime secret cannot be resolved The deploy script built its Cloud Run secret list in array assignments with one command substitution per secret. Bash gives such an assignment the exit status of its last substitution only, so with SIMULATION_DATABASE_URL added last, a failure to resolve the old gateway's client secret (an invalid name, a failed version listing, or no enabled version) no longer stopped the script, and it deployed with an empty secret reference. The Stage 12 block already hid failures for the two Modal token secrets the same way. Each secret is now resolved in its own assignment, through add_runtime_secret, so set -e stops the script at the first failure, before gcloud run deploy runs. A test covers every runtime secret; three of them deployed before this change. The test is registered under sim.deploy.workflow-shell-safety. Co-Authored-By: Claude Opus 5.5 --- .../deploy-cloud-run-simulation-entry.sh | 31 +++++++++++-------- docs/generated/rules.md | 2 +- .../tests/test_deployment_assets_cloud_run.py | 30 ++++++++++++++++++ rules/registry.toml | 5 +-- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index d0c042930..93400403d 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -58,6 +58,18 @@ resolve_secret_reference() { printf '%s:%s' "${reference}" "${highest_enabled_version}" } +runtime_secrets=() + +# Resolve one secret per assignment: an array assignment takes its exit status +# from its last command substitution only, so resolving several secrets in +# one would let an earlier failure pass unnoticed. +add_runtime_secret() { + local variable_name="$1" + local resolved_reference + resolved_reference="$(resolve_secret_reference "$2")" + runtime_secrets+=("${variable_name}=${resolved_reference}") +} + required_variables=( PROJECT_ID REGION @@ -116,16 +128,11 @@ export SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE="${SIMULATION_ENTRYPOINT_ [[ "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "0" || "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "1" ]] \ || fail "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE must be 0 or 1" +add_runtime_secret OLD_GATEWAY_AUTH_CLIENT_SECRET \ + "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}" # The runtime role's URL for the entrypoint's own simulation schema. The # migration job, which uses a separate migrator URL, runs before this deploy. -runtime_secrets=( - "OLD_GATEWAY_AUTH_CLIENT_SECRET=$( - resolve_secret_reference "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}" - )" - "SIMULATION_DATABASE_URL=$( - resolve_secret_reference "${SIMULATION_DATABASE_URL_SECRET_NAME}" - )" -) +add_runtime_secret SIMULATION_DATABASE_URL "${SIMULATION_DATABASE_URL_SECRET_NAME}" if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then stage12_variables=( @@ -144,11 +151,9 @@ if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then || fail "STAGE12_ARTIFACT_BUCKET_VALUE is invalid" validate_secret_reference "${STAGE12_DATABASE_URL_SECRET_NAME}" - runtime_secrets+=( - "MODAL_TOKEN_ID=$(resolve_secret_reference "${MODAL_TOKEN_ID_SECRET_NAME}")" - "MODAL_TOKEN_SECRET=$(resolve_secret_reference "${MODAL_TOKEN_SECRET_SECRET_NAME}")" - "STAGE12_DATABASE_URL=$(resolve_secret_reference "${STAGE12_DATABASE_URL_SECRET_NAME}")" - ) + add_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}" + add_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}" + add_runtime_secret STAGE12_DATABASE_URL "${STAGE12_DATABASE_URL_SECRET_NAME}" fi runtime_environment_file="${RUNNER_TEMP}/simulation-entry-runtime-environment.yaml" diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 55aaf3b91..ffbf2c79f 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -186,7 +186,7 @@ flowchart LR | `sim.deploy.shell-scripts-valid` | Deployment shell scripts have valid shell syntax. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.smoke-tests-strict` | Candidate and stable smoke tests reject a revision mismatch without retrying, and fail if the previous revision does not converge. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.traffic-changes-exact` | Traffic changes target an exact, validated revision, are reversible, and refuse to run over an intervening promotion. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** | -| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, and pins secret versions. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, and stops before deploying when any runtime secret cannot be resolved. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | ```mermaid flowchart LR diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py index 6c88a9cbb..baed871c3 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py @@ -163,6 +163,36 @@ def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_p assert ":latest" not in secrets_argument +@pytest.mark.parametrize( + "secret_name_variable", + [ + "OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME", + "SIMULATION_DATABASE_URL_SECRET_NAME", + "MODAL_TOKEN_ID_SECRET_NAME", + "MODAL_TOKEN_SECRET_SECRET_NAME", + "STAGE12_DATABASE_URL_SECRET_NAME", + ], +) +def test_cloud_run_deployment_stops_when_a_runtime_secret_cannot_be_resolved( + tmp_path, secret_name_variable +): + command_environment = cloud_run_deploy_environment(tmp_path) | { + secret_name_variable: "not a secret name" + } + + result = subprocess.run( + [CLOUD_RUN_DEPLOY_SCRIPT], + check=False, + capture_output=True, + text=True, + env=command_environment, + ) + + assert result.returncode != 0 + assert "invalid Secret Manager reference" in result.stderr + assert not Path(command_environment["GCLOUD_ARGUMENTS_CAPTURE"]).exists() + + @pytest.mark.parametrize( ("configured", "expected"), [(None, "0"), ("", "0"), ("0", "0"), ("1", "1")], diff --git a/rules/registry.toml b/rules/registry.toml index 998c35ed9..667352ca1 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -419,9 +419,9 @@ invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.workflow-shell-safety" category = "deployment" -statement = "Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, and pins secret versions." +statement = "Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, and stops before deploying when any runtime secret cannot be resolved." rationale = "docs/operations" -scope = [".github/workflows/*.yml"] +scope = [".github/workflows/*.yml", ".github/scripts/deploy-cloud-run-simulation-entry.sh"] tier = "mechanical" status = "enforced" autofix = false @@ -431,6 +431,7 @@ project = "projects/policyengine-simulation-entry" nodes = [ "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_does_not_interpolate_github_values_in_shell", "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_escapes_environment_and_pins_secret_versions", + "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_stops_when_a_runtime_secret_cannot_be_resolved", ] [[rule.ci]] workflow = ".github/workflows/pr.yml" From b8fe2a01f5cfb3dd5bec57a1933dc981032f68d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:00:43 +0400 Subject: [PATCH 029/124] Refuse every database URL in Modal except the comparison path's sim.persistence.no-modal-database-credentials checked only the two simulation URL names, and only in a Modal step's own env and run text. A step could still give Modal any other database URL, such as one named REPORTS_DATABASE_URL or a written-out postgresql:// URL, and a value set in the job's or the workflow's env reached the step without being checked. The check now finds any variable named like *DATABASE_URL* and any written-out PostgreSQL URL in the workflow, job and step environments of a step that creates a Modal secret or deploys a Modal application, in the scripts such a step runs, and in the executor's and gateway's sources. Only the comparison path's STAGE12_DATABASE_URL, and the name of the secret that holds it, may reach Modal until the comparison path is removed. The rule's statement and the library README say so, and the new tests are registered under the rule. Co-Authored-By: Claude Opus 5.5 --- docs/generated/rules.md | 2 +- .../README.md | 14 ++- .../tests/ownership_checks.py | 101 +++++++++++++----- .../tests/test_ownership_boundaries.py | 83 ++++++++++++-- rules/registry.toml | 12 ++- 5 files changed, 170 insertions(+), 42 deletions(-) diff --git a/docs/generated/rules.md b/docs/generated/rules.md index ffbf2c79f..853bc0aae 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -143,7 +143,7 @@ flowchart LR | `sim.persistence.executor-independent` | The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | | `sim.persistence.migrate-before-entrypoint` | Each deployment applies the simulation chain in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py` | `pr.yml:test` | **unproven** | | `sim.persistence.migration-cycle` | On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py` | `pr.yml:simulation-database` | **unproven** | -| `sim.persistence.no-modal-database-credentials` | No Modal secret, Modal deployment or Modal application receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL; until the comparison path is removed, its own database secret is the only database credential Modal holds. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | +| `sim.persistence.no-modal-database-credentials` | No Modal secret, Modal deployment or Modal application receives a database URL other than the comparison path's own STAGE12_DATABASE_URL, which Modal may hold until the comparison path is removed; in particular never SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | | `sim.persistence.one-migration-chain` | The simulation schema changes only through the one Alembic chain at migrations/, configured by migrations/alembic.ini and versioned in simulation.alembic_version; no other Alembic configuration, environment or revision directory exists. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | ```mermaid diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index d74612eb8..2264081e5 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -25,11 +25,15 @@ These rules are registered in `rules/registry.toml` and checked by never declare, lock or import this library. Only the Simulation Entrypoint and the migration job install it. - **No database credentials in Modal** - (`sim.persistence.no-modal-database-credentials`). No Modal secret or Modal - deployment receives `SIMULATION_DATABASE_URL` or - `SIMULATION_MIGRATION_DATABASE_URL`. The Stage 12 comparison's own database - secret is the only database credential Modal holds, until the comparison - path is removed. + (`sim.persistence.no-modal-database-credentials`). No Modal secret, Modal + deployment or Modal application source receives or names a database URL: + neither a variable named like `*DATABASE_URL*` nor a written-out + `postgresql://` URL, whether in a step's, job's or workflow's environment + or in a script such a step runs. The one exception is the Stage 12 + comparison's own `STAGE12_DATABASE_URL` (and the name of the secret that + holds it), until the comparison path is removed. In particular, + `SIMULATION_DATABASE_URL` and `SIMULATION_MIGRATION_DATABASE_URL` never + reach Modal. ## Database roles diff --git a/libs/policyengine-simulation-persistence/tests/ownership_checks.py b/libs/policyengine-simulation-persistence/tests/ownership_checks.py index 5e2e0d9bf..526ee46c9 100644 --- a/libs/policyengine-simulation-persistence/tests/ownership_checks.py +++ b/libs/policyengine-simulation-persistence/tests/ownership_checks.py @@ -31,7 +31,15 @@ IGNORED_RELATIVE_DIRECTORIES = (Path("rules") / "fixtures",) PERSISTENCE_DISTRIBUTION = "policyengine-simulation-persistence" PERSISTENCE_PACKAGE = "policyengine_simulation_persistence" -SIMULATION_DATABASE_CREDENTIAL = re.compile(r"SIMULATION_(?:MIGRATION_)?DATABASE_URL") +# Any variable named like a database URL, or a PostgreSQL URL written out. +DATABASE_CREDENTIAL = re.compile( + r"\b[A-Z][A-Z0-9_]*DATABASE_URL[A-Z0-9_]*\b|\bpostgres(?:ql)?(?:\+\w+)?://" +) +# The comparison path's own database URL, and the name of the Secret Manager +# secret that holds it, may reach Modal until the comparison path is removed. +COMPARISON_PATH_CREDENTIALS = frozenset( + {"STAGE12_DATABASE_URL", "STAGE12_DATABASE_URL_SECRET_NAME"} +) MODAL_SECRET_COMMAND = re.compile(r"\bmodal\s+secret\s+create\b") MODAL_DEPLOY_COMMAND = re.compile(r"\bmodal\s+deploy\b") @@ -156,6 +164,23 @@ def persistence_dependency_violations(project_root: Path) -> list[str]: return violations +def _foreign_credentials(text: str) -> list[str]: + """Return the database credentials in ``text`` other than the comparison path's.""" + return sorted( + { + match + for match in DATABASE_CREDENTIAL.findall(text) + if match not in COMPARISON_PATH_CREDENTIALS + } + ) + + +def _environment_text(environment: object) -> str: + if not isinstance(environment, dict): + return "" + return "\n".join(f"{key}={value}" for key, value in environment.items()) + + def _step_text(step: Mapping[str, object]) -> str: return "\n".join( str(value) @@ -171,24 +196,50 @@ def _step_reaches_modal(step_text: str, modal_scripts: set[str]) -> bool: ) -def _step_carries_credential(step: Mapping[str, object], step_text: str) -> bool: - environment = step.get("env") or {} - assert isinstance(environment, dict) - return bool(SIMULATION_DATABASE_CREDENTIAL.search(step_text)) or any( - SIMULATION_DATABASE_CREDENTIAL.search(f"{key}={value}") - for key, value in environment.items() - ) +def _workflow_violations( + workflow_name: str, workflow_text: str, modal_scripts: set[str] +) -> list[str]: + workflow = yaml.safe_load(workflow_text) or {} + workflow_environment = _environment_text(workflow.get("env")) + violations: list[str] = [] + for job_name, job in (workflow.get("jobs") or {}).items(): + job_environment = _environment_text(job.get("env")) + for step in job.get("steps") or []: + step_text = _step_text(step) + if not _step_reaches_modal(step_text, modal_scripts): + continue + # Workflow-level and job-level env reach every step of the job. + carried = _foreign_credentials( + "\n".join( + ( + workflow_environment, + job_environment, + _environment_text(step.get("env")), + step_text, + ) + ) + ) + if carried: + violations.append( + f"{workflow_name} job {job_name} step " + f"{step.get('name', '')!r} gives Modal a database " + f"credential other than the comparison path's: " + f"{', '.join(carried)}" + ) + return violations def modal_credential_violations( workflow_texts: Mapping[str, str], script_texts: Mapping[str, str], ) -> list[str]: - """Return Modal secret or deploy steps that carry simulation database URLs. + """Return Modal secret or deploy steps that carry a foreign database URL. ``workflow_texts`` and ``script_texts`` map a file name to its contents. A step counts as giving Modal a value when it creates a Modal secret or - deploys a Modal application, directly or through a script that does. + deploys a Modal application, directly or through a script that does, and + every value in its step, job or workflow environment reaches it. Only the + comparison path's own database URL may reach Modal. """ modal_scripts = { name @@ -196,21 +247,23 @@ def modal_credential_violations( if MODAL_SECRET_COMMAND.search(text) or MODAL_DEPLOY_COMMAND.search(text) } violations = [ - f"{name} gives Modal a simulation database credential" + f"{name} gives Modal a database credential other than the comparison " + f"path's: {', '.join(carried)}" for name in sorted(modal_scripts) - if SIMULATION_DATABASE_CREDENTIAL.search(script_texts[name]) + if (carried := _foreign_credentials(script_texts[name])) ] for workflow_name, workflow_text in sorted(workflow_texts.items()): - workflow = yaml.safe_load(workflow_text) or {} - for job_name, job in (workflow.get("jobs") or {}).items(): - for step in job.get("steps") or []: - step_text = _step_text(step) - if _step_reaches_modal( - step_text, modal_scripts - ) and _step_carries_credential(step, step_text): - violations.append( - f"{workflow_name} job {job_name} step " - f"{step.get('name', '')!r} gives Modal a " - "simulation database credential" - ) + violations.extend( + _workflow_violations(workflow_name, workflow_text, modal_scripts) + ) return violations + + +def modal_source_violations(sources: Mapping[str, str]) -> list[str]: + """Return Modal project sources that name a foreign database URL variable.""" + return [ + f"{name} names {variable}" + for name, source in sorted(sources.items()) + for variable in _foreign_credentials(source) + if not variable.endswith("://") + ] diff --git a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py index 8d3b3d8ba..f6336d587 100644 --- a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py +++ b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py @@ -2,11 +2,13 @@ from __future__ import annotations +import pytest from migration_support import REPOSITORY_ROOT from ownership_checks import ( declares_persistence_dependency, migration_chain_violations, modal_credential_violations, + modal_source_violations, persistence_dependency_violations, ) @@ -17,10 +19,6 @@ REPOSITORY_ROOT / "projects" / "policyengine-simulation-executor", REPOSITORY_ROOT / "projects" / "policyengine-simulation-gateway", ) -SIMULATION_DATABASE_VARIABLES = ( - "SIMULATION_DATABASE_URL", - "SIMULATION_MIGRATION_DATABASE_URL", -) def _texts(directory, pattern): @@ -59,7 +57,7 @@ def test_rule_proof_executor_independent() -> None: ) -def test_modal_never_receives_simulation_database_credentials() -> None: +def test_modal_receives_no_database_url_but_the_comparison_paths() -> None: assert ( modal_credential_violations( _texts(WORKFLOWS, "*.yml"), @@ -68,10 +66,11 @@ def test_modal_never_receives_simulation_database_credentials() -> None: == [] ) for project_root in MODAL_PROJECTS: - for path in sorted((project_root / "src").rglob("*.py")): - source = path.read_text(encoding="utf-8") - for variable in SIMULATION_DATABASE_VARIABLES: - assert variable not in source, path + sources = { + str(path): path.read_text(encoding="utf-8") + for path in sorted((project_root / "src").rglob("*.py")) + } + assert modal_source_violations(sources) == [] def test_rule_proof_no_modal_database_credentials() -> None: @@ -87,3 +86,69 @@ def test_rule_proof_no_modal_database_credentials() -> None: {"good.yml": (fixtures / "good_workflow.yml").read_text(encoding="utf-8")}, no_scripts, ) + + +def _modal_secret_workflow(*, step_env: str = "", job_env: str = "") -> str: + return ( + "jobs:\n" + " deploy:\n" + f"{job_env}" + " steps:\n" + " - name: Synchronize Modal secrets\n" + f"{step_env}" + " run: uv run modal secret create runtime --from-dotenv .env\n" + ) + + +@pytest.mark.parametrize( + ("step_env", "job_env"), + [ + (" env:\n REPORTS_DATABASE_URL: x\n", ""), + (" env:\n SIMULATION_DATABASE_URL: x\n", ""), + ("", " env:\n SIMULATION_MIGRATION_DATABASE_URL: x\n"), + (" env:\n DSN: postgresql://user:secret@db/postgres\n", ""), + ], + ids=["any-database-url", "simulation-url", "job-level-env", "url-literal"], +) +def test_modal_steps_may_carry_only_the_comparison_paths_database_url( + step_env, job_env +) -> None: + workflow = _modal_secret_workflow(step_env=step_env, job_env=job_env) + + assert modal_credential_violations({"deploy.yml": workflow}, {}) != [] + + +def test_the_comparison_paths_database_url_may_reach_modal_until_it_is_removed() -> ( + None +): + workflow = _modal_secret_workflow( + step_env=( + " env:\n" + " STAGE12_DATABASE_URL_SECRET_NAME: stage12-database-url\n" + ) + ) + script = "jq -n '{STAGE12_DATABASE_URL: $url}'\nuv run modal secret create x\n" + + assert modal_credential_violations({"deploy.yml": workflow}, {}) == [] + assert modal_credential_violations({}, {"stage12-sync.sh": script}) == [] + + +def test_a_modal_script_may_not_carry_another_database_url() -> None: + script = ( + "uv run modal secret create x REPORTS_DATABASE_URL=${REPORTS_DATABASE_URL}\n" + ) + + assert modal_credential_violations({}, {"sync.sh": script}) == [ + "sync.sh gives Modal a database credential other than the comparison " + "path's: REPORTS_DATABASE_URL" + ] + + +def test_modal_sources_may_name_only_the_comparison_paths_database_url() -> None: + assert ( + modal_source_violations({"worker.py": "os.environ['STAGE12_DATABASE_URL']"}) + == [] + ) + assert modal_source_violations( + {"worker.py": "os.environ['SIMULATION_DATABASE_URL']"} + ) == ["worker.py names SIMULATION_DATABASE_URL"] diff --git a/rules/registry.toml b/rules/registry.toml index 667352ca1..b7d7727b0 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -210,16 +210,22 @@ test = "tests/test_ownership_boundaries.py::test_rule_proof_executor_independent [[rule]] id = "sim.persistence.no-modal-database-credentials" category = "persistence" -statement = "No Modal secret, Modal deployment or Modal application receives SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL; until the comparison path is removed, its own database secret is the only database credential Modal holds." +statement = "No Modal secret, Modal deployment or Modal application receives a database URL other than the comparison path's own STAGE12_DATABASE_URL, which Modal may hold until the comparison path is removed; in particular never SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL." rationale = "libs/policyengine-simulation-persistence/README.md#ownership-rules" -scope = [".github/workflows/*.yml", ".github/scripts/*.sh", "projects/policyengine-simulation-executor/src/**/*.py"] +scope = [".github/workflows/*.yml", ".github/scripts/*.sh", "projects/policyengine-simulation-executor/src/**/*.py", "projects/policyengine-simulation-gateway/src/**/*.py"] tier = "mechanical" status = "enforced" autofix = false [rule.enforcer] kind = "pytest" project = "libs/policyengine-simulation-persistence" -nodes = ["tests/test_ownership_boundaries.py::test_modal_never_receives_simulation_database_credentials"] +nodes = [ + "tests/test_ownership_boundaries.py::test_modal_receives_no_database_url_but_the_comparison_paths", + "tests/test_ownership_boundaries.py::test_modal_steps_may_carry_only_the_comparison_paths_database_url", + "tests/test_ownership_boundaries.py::test_the_comparison_paths_database_url_may_reach_modal_until_it_is_removed", + "tests/test_ownership_boundaries.py::test_a_modal_script_may_not_carry_another_database_url", + "tests/test_ownership_boundaries.py::test_modal_sources_may_name_only_the_comparison_paths_database_url", +] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" From e0a883f1d9c9d30083ff8dda0f8744320a3fd354 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:39:39 +0400 Subject: [PATCH 030/124] Refuse a bare DATABASE_URL in Modal too The Modal credential check required at least one character before DATABASE_URL, so a Modal step or a Modal application source using a variable named exactly DATABASE_URL passed it, although the rule allows no database URL in Modal except the comparison path's. The pattern now matches the bare name, and the tests cover it in a workflow step and in a source. Co-Authored-By: Claude Opus 5.5 --- .../tests/ownership_checks.py | 5 +++-- .../tests/test_ownership_boundaries.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/policyengine-simulation-persistence/tests/ownership_checks.py b/libs/policyengine-simulation-persistence/tests/ownership_checks.py index 526ee46c9..276cd3e60 100644 --- a/libs/policyengine-simulation-persistence/tests/ownership_checks.py +++ b/libs/policyengine-simulation-persistence/tests/ownership_checks.py @@ -31,9 +31,10 @@ IGNORED_RELATIVE_DIRECTORIES = (Path("rules") / "fixtures",) PERSISTENCE_DISTRIBUTION = "policyengine-simulation-persistence" PERSISTENCE_PACKAGE = "policyengine_simulation_persistence" -# Any variable named like a database URL, or a PostgreSQL URL written out. +# Any variable named like a database URL (DATABASE_URL itself included), or a +# PostgreSQL URL written out. DATABASE_CREDENTIAL = re.compile( - r"\b[A-Z][A-Z0-9_]*DATABASE_URL[A-Z0-9_]*\b|\bpostgres(?:ql)?(?:\+\w+)?://" + r"\b[A-Z0-9_]*DATABASE_URL[A-Z0-9_]*\b|\bpostgres(?:ql)?(?:\+\w+)?://" ) # The comparison path's own database URL, and the name of the Secret Manager # secret that holds it, may reach Modal until the comparison path is removed. diff --git a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py index f6336d587..5215f8905 100644 --- a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py +++ b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py @@ -104,11 +104,18 @@ def _modal_secret_workflow(*, step_env: str = "", job_env: str = "") -> str: ("step_env", "job_env"), [ (" env:\n REPORTS_DATABASE_URL: x\n", ""), + (" env:\n DATABASE_URL: x\n", ""), (" env:\n SIMULATION_DATABASE_URL: x\n", ""), ("", " env:\n SIMULATION_MIGRATION_DATABASE_URL: x\n"), (" env:\n DSN: postgresql://user:secret@db/postgres\n", ""), ], - ids=["any-database-url", "simulation-url", "job-level-env", "url-literal"], + ids=[ + "any-database-url", + "bare-database-url", + "simulation-url", + "job-level-env", + "url-literal", + ], ) def test_modal_steps_may_carry_only_the_comparison_paths_database_url( step_env, job_env @@ -152,3 +159,6 @@ def test_modal_sources_may_name_only_the_comparison_paths_database_url() -> None assert modal_source_violations( {"worker.py": "os.environ['SIMULATION_DATABASE_URL']"} ) == ["worker.py names SIMULATION_DATABASE_URL"] + assert modal_source_violations({"worker.py": "os.getenv('DATABASE_URL')"}) == [ + "worker.py names DATABASE_URL" + ] From 5500c5e438851936e1f9ec36e5bee351b37d786e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:43:19 +0400 Subject: [PATCH 031/124] Apply migrations only from main; elsewhere require the database at the head The Stage 12 deploy workflow can be dispatched from any branch, and it uses the same reusable workflow, so it would have applied that branch's unmerged revisions to the beta or prod database; every later deploy from main would then fail, because the applied revision is not in main's chain. The migration step now sets SIMULATION_MIGRATION_MODE from the ref: upgrade on main (alembic upgrade head, then current --check-heads), verify elsewhere (only current --check-heads). The script refuses a missing or unknown mode. Its unused SIMULATION_ALEMBIC_CONFIG override is gone. alembic current --check-heads first appeared in Alembic 1.17.1, so the persistence library's floor rises from 1.16 to 1.17.1 (the lock already pins 1.20.0). A new PostgreSQL test runs the script itself against a fresh database: verify refuses it, upgrade migrates it to the head, and verify then passes. Co-Authored-By: Claude Opus 5.5 --- .../scripts/migrate-simulation-database.sh | 32 ++++++-- .../workflows/simulation-deploy.reusable.yml | 3 + docs/generated/rules.md | 2 +- .../README.md | 12 ++- .../pyproject.toml | 5 +- .../tests/test_migration_script.py | 61 ++++++++++++++ .../uv.lock | 4 +- .../DEPLOYMENT.md | 9 ++- .../test_simulation_database_deployment.py | 81 ++++++++++++++----- .../policyengine-simulation-entry/uv.lock | 4 +- rules/registry.toml | 3 +- 11 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/tests/test_migration_script.py diff --git a/.github/scripts/migrate-simulation-database.sh b/.github/scripts/migrate-simulation-database.sh index 0572ea43f..88bc3d84e 100755 --- a/.github/scripts/migrate-simulation-database.sh +++ b/.github/scripts/migrate-simulation-database.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Apply the simulation schema's Alembic chain, then require the database to be -# at the chain's head. Run from libs/policyengine-simulation-persistence, whose -# locked environment (with the migrations extra) provides Alembic. -# migrations/env.py reads the migrator URL from SIMULATION_MIGRATION_DATABASE_URL; -# this script never prints it. +# Bring the simulation schema to the chain's head, or require it to be there. +# Run from libs/policyengine-simulation-persistence, whose locked environment +# (with the migrations extra) provides Alembic. migrations/env.py reads the +# migrator URL from SIMULATION_MIGRATION_DATABASE_URL; this script never +# prints it. +# +# SIMULATION_MIGRATION_MODE: +# upgrade apply every pending revision, then require the database at the +# head (deployments from the default branch); +# verify apply nothing and only require the database at this checkout's +# head, so a deployment dispatched from another branch can never +# apply that branch's revisions. set -euo pipefail set +x @@ -13,7 +20,18 @@ if [[ -z "${SIMULATION_MIGRATION_DATABASE_URL:-}" ]]; then exit 1 fi -alembic_config="${SIMULATION_ALEMBIC_CONFIG:-../../migrations/alembic.ini}" +mode="${SIMULATION_MIGRATION_MODE:-}" +case "${mode}" in + upgrade | verify) ;; + *) + printf 'SIMULATION_MIGRATION_MODE must be upgrade or verify, not %s\n' "${mode:-empty}" >&2 + exit 1 + ;; +esac -uv run --frozen --extra migrations alembic -c "${alembic_config}" upgrade head +alembic_config="../../migrations/alembic.ini" + +if [[ "${mode}" == "upgrade" ]]; then + uv run --frozen --extra migrations alembic -c "${alembic_config}" upgrade head +fi uv run --frozen --extra migrations alembic -c "${alembic_config}" current --check-heads diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 00e0e5d70..15990f525 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -178,6 +178,9 @@ jobs: working-directory: libs/policyengine-simulation-persistence env: SIMULATION_MIGRATION_DATABASE_URL: ${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }} + # Only a deployment from main applies revisions. One dispatched from + # another branch requires the database to be at its chain's head. + SIMULATION_MIGRATION_MODE: ${{ github.ref == 'refs/heads/main' && 'upgrade' || 'verify' }} run: ../../.github/scripts/migrate-simulation-database.sh deploy_entrypoint: diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 853bc0aae..26b11af9a 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -141,7 +141,7 @@ flowchart LR | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | | --- | --- | --- | --- | --- | --- | --- | --- | | `sim.persistence.executor-independent` | The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | -| `sim.persistence.migrate-before-entrypoint` | Each deployment applies the simulation chain in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py` | `pr.yml:test` | **unproven** | +| `sim.persistence.migrate-before-entrypoint` | Each deployment migrates the simulation schema in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys, applying revisions only from main and otherwise requiring the database at the chain's head; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py` | `pr.yml:test` | **unproven** | | `sim.persistence.migration-cycle` | On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py` | `pr.yml:simulation-database` | **unproven** | | `sim.persistence.no-modal-database-credentials` | No Modal secret, Modal deployment or Modal application receives a database URL other than the comparison path's own STAGE12_DATABASE_URL, which Modal may hold until the comparison path is removed; in particular never SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | | `sim.persistence.one-migration-chain` | The simulation schema changes only through the one Alembic chain at migrations/, configured by migrations/alembic.ini and versioned in simulation.alembic_version; no other Alembic configuration, environment or revision directory exists. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 2264081e5..be8240e3a 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -54,10 +54,14 @@ PostgreSQL checks `CREATE` on the database even for On every deployment, the migration job (`migrate_simulation_database` in `.github/workflows/simulation-deploy.reusable.yml`) runs -`alembic upgrade head` before the Simulation Entrypoint's candidate revision is -deployed (`sim.persistence.migrate-before-entrypoint`). It is the only job that -receives `SIMULATION_MIGRATION_DATABASE_URL`. If it fails, the entrypoint is not -deployed. The Simulation Entrypoint's readiness check (`GET /ready`) reports +`.github/scripts/migrate-simulation-database.sh` before the Simulation +Entrypoint's candidate revision is deployed +(`sim.persistence.migrate-before-entrypoint`). From `main` it runs +`alembic upgrade head` and then `alembic current --check-heads`; from any other +branch it runs only the head check, so a deployment dispatched from an unmerged +branch fails instead of applying that branch's revisions. It is the only job +that receives `SIMULATION_MIGRATION_DATABASE_URL`. If it fails, the entrypoint +is not deployed. The Simulation Entrypoint's readiness check (`GET /ready`) reports the database's revision next to `HEAD_REVISION`, the revision its image expects, and the candidate check requires the two to match. diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml index 4dab6a1a1..28ab475b5 100644 --- a/libs/policyengine-simulation-persistence/pyproject.toml +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -14,11 +14,12 @@ dependencies = [ [project.optional-dependencies] # The migration job installs Alembic; the Simulation Entrypoint runtime does not. +# 1.17.1 added `alembic current --check-heads`, which the migration job runs. migrations = [ - "alembic>=1.16,<2", + "alembic>=1.17.1,<2", ] test = [ - "alembic>=1.16,<2", + "alembic>=1.17.1,<2", "pytest>=8.3.4", "pytest-cov>=6.1.1", "pyyaml>=6.0.2", diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_script.py b/libs/policyengine-simulation-persistence/tests/test_migration_script.py new file mode 100644 index 000000000..b4d0ebc2e --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_migration_script.py @@ -0,0 +1,61 @@ +"""The deploy job's migration script, run for real against PostgreSQL.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +from migration_support import MIGRATION_DATABASE_URL_VARIABLE, REPOSITORY_ROOT +from policyengine_simulation_persistence import ( + HEAD_REVISION, + create_simulation_engine, + read_schema_revision, +) + +pytestmark = pytest.mark.postgres + +SCRIPT = REPOSITORY_ROOT / ".github" / "scripts" / "migrate-simulation-database.sh" +LIBRARY_DIRECTORY = Path(__file__).resolve().parents[1] + + +def _run_script(database, mode: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT)], + cwd=LIBRARY_DIRECTORY, + env={ + **os.environ, + MIGRATION_DATABASE_URL_VARIABLE: database.migrator_url, + "SIMULATION_MIGRATION_MODE": mode, + }, + capture_output=True, + text=True, + check=False, + ) + + +def _revision(database) -> str | None: + engine = create_simulation_engine(database.migrator_url) + try: + with engine.connect() as connection: + return read_schema_revision(connection) + finally: + engine.dispose() + + +def test_verify_mode_refuses_a_database_behind_the_chain(disposable_database) -> None: + result = _run_script(disposable_database, "verify") + + assert result.returncode != 0 + assert _revision(disposable_database) is None + + +def test_upgrade_mode_migrates_and_then_verify_mode_passes(disposable_database) -> None: + upgraded = _run_script(disposable_database, "upgrade") + assert upgraded.returncode == 0, upgraded.stderr + assert _revision(disposable_database) == HEAD_REVISION + + verified = _run_script(disposable_database, "verify") + assert verified.returncode == 0, verified.stderr + assert "simulation-test-password" not in upgraded.stdout + upgraded.stderr diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock index a617c4024..b007a60ff 100644 --- a/libs/policyengine-simulation-persistence/uv.lock +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -150,8 +150,8 @@ test = [ [package.metadata] requires-dist = [ - { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.16,<2" }, - { name = "alembic", marker = "extra == 'test'", specifier = ">=1.16,<2" }, + { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.17.1,<2" }, + { name = "alembic", marker = "extra == 'test'", specifier = ">=1.17.1,<2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 1716be9a7..de36a6b31 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -30,7 +30,14 @@ in each environment, the `migrate_simulation_database` job applies the schema's Alembic chain (`migrations/`) with the migrator role. It is the only job that receives `SIMULATION_MIGRATION_DATABASE_URL`, a GitHub environment secret in `beta` and `prod`, passed in that step's `env:` alone. If the -migration fails, the entrypoint is not deployed. +migration fails, the entrypoint is not deployed. Only a deployment from `main` +applies revisions; one dispatched from another branch (the Stage 12 deploy can +be) only requires the database to be at that branch's head, so an unmerged +revision never reaches a shared database. + +The deploy service account must be able to list the versions of the runtime +URL's secret (`gcloud secrets versions list`), as for the other runtime +secrets. The entrypoint itself connects as the runtime role. The deploy step resolves the Secret Manager secret named by the `SIMULATION_DATABASE_URL_SECRET_NAME` diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py index 043367217..374c12c0c 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py @@ -74,7 +74,10 @@ def test_the_migration_job_applies_the_chain_with_the_migrator_url_alone(): assert apply_step["working-directory"] == "libs/policyengine-simulation-persistence" assert apply_step["run"] == f"../../.github/scripts/{MIGRATION_SCRIPT}" assert apply_step["env"] == { - MIGRATION_SECRET: "${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }}" + MIGRATION_SECRET: "${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }}", + "SIMULATION_MIGRATION_MODE": ( + "${{ github.ref == 'refs/heads/main' && 'upgrade' || 'verify' }}" + ), } assert "env" not in migration assert "permissions" not in migration @@ -101,32 +104,56 @@ def test_only_the_migration_step_receives_the_migrator_url(): ] +UV_ALEMBIC = ["uv", "run", "--frozen", "--extra", "migrations", "alembic", "-c", + "../../migrations/alembic.ini"] # fmt: skip + + +def _run_migration_script(fakes, **environment): + values = { + "SIMULATION_MIGRATION_DATABASE_URL": FAKE_MIGRATION_URL, + "SIMULATION_MIGRATION_MODE": "upgrade", + **environment, + } + return run_script(MIGRATION_SCRIPT, env=fakes.env(**values)) + + def test_the_migration_script_upgrades_and_then_requires_the_head(fakes): fakes.add("uv") - result = run_script( - MIGRATION_SCRIPT, - env=fakes.env(SIMULATION_MIGRATION_DATABASE_URL=FAKE_MIGRATION_URL), - ) + result = _run_migration_script(fakes) assert result.returncode == 0, result.stderr - config = "../../migrations/alembic.ini" assert fakes.calls("uv") == [ - ["uv", "run", "--frozen", "--extra", "migrations", "alembic", "-c", config, - "upgrade", "head"], - ["uv", "run", "--frozen", "--extra", "migrations", "alembic", "-c", config, - "current", "--check-heads"], - ] # fmt: skip + [*UV_ALEMBIC, "upgrade", "head"], + [*UV_ALEMBIC, "current", "--check-heads"], + ] assert "secret" not in result.stdout + result.stderr +def test_verify_mode_only_requires_the_database_at_the_head(fakes): + fakes.add("uv") + + result = _run_migration_script(fakes, SIMULATION_MIGRATION_MODE="verify") + + assert result.returncode == 0, result.stderr + assert fakes.calls("uv") == [[*UV_ALEMBIC, "current", "--check-heads"]] + + +@pytest.mark.parametrize("mode", ["", "downgrade", "UPGRADE"]) +def test_the_migration_script_refuses_an_unknown_mode(fakes, mode): + fakes.add("uv") + + result = _run_migration_script(fakes, SIMULATION_MIGRATION_MODE=mode) + + assert result.returncode != 0 + assert "SIMULATION_MIGRATION_MODE" in result.stderr + assert fakes.calls("uv") == [] + + def test_the_migration_script_refuses_to_run_without_the_migrator_url(fakes): fakes.add("uv") - result = run_script( - MIGRATION_SCRIPT, - env=fakes.env(SIMULATION_MIGRATION_DATABASE_URL=""), - ) + result = _run_migration_script(fakes, SIMULATION_MIGRATION_DATABASE_URL="") assert result.returncode != 0 assert MIGRATION_SECRET in result.stderr @@ -136,15 +163,31 @@ def test_the_migration_script_refuses_to_run_without_the_migrator_url(fakes): def test_a_failed_upgrade_fails_the_job_before_the_head_check(fakes): fakes.add("uv", 'if [[ " $* " == *" upgrade "* ]]; then exit 3; fi\n') - result = run_script( - MIGRATION_SCRIPT, - env=fakes.env(SIMULATION_MIGRATION_DATABASE_URL=FAKE_MIGRATION_URL), - ) + result = _run_migration_script(fakes) assert result.returncode == 3 assert len(fakes.calls("uv")) == 1 +def test_only_the_default_branch_applies_migrations(): + migration = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)[MIGRATION_JOB] + steps = {step.get("name"): step for step in migration["steps"]} + mode = steps["Apply simulation schema migrations"]["env"][ + "SIMULATION_MIGRATION_MODE" + ] + + # A deployment dispatched from another branch must not apply that branch's + # revisions; it only requires the database to be at its chain's head. + assert ( + evaluate_github_expression(mode, {"github": {"ref": "refs/heads/main"}}) + == "upgrade" + ) + assert ( + evaluate_github_expression(mode, {"github": {"ref": "refs/heads/feature"}}) + == "verify" + ) + + # --- The entrypoint's runtime URL ----------------------------------------- diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index b472cb3ed..31a2409dd 100644 --- a/projects/policyengine-simulation-entry/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -976,8 +976,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.16,<2" }, - { name = "alembic", marker = "extra == 'test'", specifier = ">=1.16,<2" }, + { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.17.1,<2" }, + { name = "alembic", marker = "extra == 'test'", specifier = ">=1.17.1,<2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, diff --git a/rules/registry.toml b/rules/registry.toml index b7d7727b0..b9b1e7bbc 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -239,7 +239,7 @@ test = "tests/test_ownership_boundaries.py::test_rule_proof_no_modal_database_cr [[rule]] id = "sim.persistence.migrate-before-entrypoint" category = "persistence" -statement = "Each deployment applies the simulation chain in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision." +statement = "Each deployment migrates the simulation schema in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys, applying revisions only from main and otherwise requiring the database at the chain's head; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision." rationale = "libs/policyengine-simulation-persistence/README.md#deployment" scope = [".github/workflows/simulation-deploy.reusable.yml", ".github/scripts/migrate-simulation-database.sh", ".github/scripts/deploy-cloud-run-simulation-entry.sh", ".github/scripts/cloud-run-simulation-entry-smoke.sh"] tier = "mechanical" @@ -251,6 +251,7 @@ project = "projects/policyengine-simulation-entry" nodes = [ "tests/test_simulation_database_deployment.py::test_the_migration_job_runs_before_the_entrypoint_deploys", "tests/test_simulation_database_deployment.py::test_only_the_migration_step_receives_the_migrator_url", + "tests/test_simulation_database_deployment.py::test_only_the_default_branch_applies_migrations", "tests/test_simulation_database_deployment.py::test_the_entrypoint_receives_the_runtime_url_as_a_pinned_secret", "tests/test_simulation_database_deployment.py::test_the_candidate_smoke_requires_the_current_database_revision", ] From 2233d4cfb9c5ef6c192309b3bf42779408170032 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:44:32 +0400 Subject: [PATCH 032/124] Resolve the Stage 12 secrets with the Stage 12 block's own helper The fix that resolves each runtime secret in its own assignment introduced add_runtime_secret and used it for the main path's secrets and for the Stage 12 comparison path's Modal token and database secrets alike. The comparison path keeps its own mechanisms, so it can be removed without touching the main path: the Stage 12 block now uses add_stage12_runtime_secret, its own copy with the same set -e behavior. A test checks that each block uses only its own helper; it is registered under sim.deploy.workflow-shell-safety. Co-Authored-By: Claude Opus 5.5 --- .../scripts/deploy-cloud-run-simulation-entry.sh | 15 ++++++++++++--- docs/generated/rules.md | 2 +- .../tests/test_deployment_assets_cloud_run.py | 15 +++++++++++++++ rules/registry.toml | 3 ++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 93400403d..183fc3329 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -70,6 +70,15 @@ add_runtime_secret() { runtime_secrets+=("${variable_name}=${resolved_reference}") } +# The Stage 12 comparison path's own copy, so it shares no deploy helper with +# the main path and is removed with it. +add_stage12_runtime_secret() { + local variable_name="$1" + local resolved_reference + resolved_reference="$(resolve_secret_reference "$2")" + runtime_secrets+=("${variable_name}=${resolved_reference}") +} + required_variables=( PROJECT_ID REGION @@ -151,9 +160,9 @@ if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then || fail "STAGE12_ARTIFACT_BUCKET_VALUE is invalid" validate_secret_reference "${STAGE12_DATABASE_URL_SECRET_NAME}" - add_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}" - add_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}" - add_runtime_secret STAGE12_DATABASE_URL "${STAGE12_DATABASE_URL_SECRET_NAME}" + add_stage12_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}" + add_stage12_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}" + add_stage12_runtime_secret STAGE12_DATABASE_URL "${STAGE12_DATABASE_URL_SECRET_NAME}" fi runtime_environment_file="${RUNNER_TEMP}/simulation-entry-runtime-environment.yaml" diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 26b11af9a..6c88ffb38 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -186,7 +186,7 @@ flowchart LR | `sim.deploy.shell-scripts-valid` | Deployment shell scripts have valid shell syntax. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.smoke-tests-strict` | Candidate and stable smoke tests reject a revision mismatch without retrying, and fail if the previous revision does not converge. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.traffic-changes-exact` | Traffic changes target an exact, validated revision, are reversible, and refuse to run over an intervening promotion. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** | -| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, and stops before deploying when any runtime secret cannot be resolved. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, stops before deploying when any runtime secret cannot be resolved, and resolves the Stage 12 secrets with the Stage 12 block's own helper. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | ```mermaid flowchart LR diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py index baed871c3..a6db99073 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py @@ -193,6 +193,21 @@ def test_cloud_run_deployment_stops_when_a_runtime_secret_cannot_be_resolved( assert not Path(command_environment["GCLOUD_ARGUMENTS_CAPTURE"]).exists() +def test_stage12_secrets_are_resolved_by_the_stage12_block_alone(): + """The comparison path shares no deploy helper with the main path.""" + script = Path(CLOUD_RUN_DEPLOY_SCRIPT).read_text(encoding="utf-8") + stage12_block = script.split('if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then', 1)[ + 1 + ].split("\nfi\n", 1)[0] + main_path = script.replace(stage12_block, "") + + assert "add_stage12_runtime_secret STAGE12_DATABASE_URL" in stage12_block + assert "add_runtime_secret " not in stage12_block + assert "add_stage12_runtime_secret " not in main_path.replace( + "add_stage12_runtime_secret() {", "" + ) + + @pytest.mark.parametrize( ("configured", "expected"), [(None, "0"), ("", "0"), ("0", "0"), ("1", "1")], diff --git a/rules/registry.toml b/rules/registry.toml index b9b1e7bbc..b5acf71ca 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -426,7 +426,7 @@ invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.workflow-shell-safety" category = "deployment" -statement = "Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, and stops before deploying when any runtime secret cannot be resolved." +statement = "Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, stops before deploying when any runtime secret cannot be resolved, and resolves the Stage 12 secrets with the Stage 12 block's own helper." rationale = "docs/operations" scope = [".github/workflows/*.yml", ".github/scripts/deploy-cloud-run-simulation-entry.sh"] tier = "mechanical" @@ -439,6 +439,7 @@ nodes = [ "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_does_not_interpolate_github_values_in_shell", "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_escapes_environment_and_pins_secret_versions", "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_stops_when_a_runtime_secret_cannot_be_resolved", + "tests/test_deployment_assets_cloud_run.py::test_stage12_secrets_are_resolved_by_the_stage12_block_alone", ] [[rule.ci]] workflow = ".github/workflows/pr.yml" From baffc50e409076444ecc2660aedccc5acf539064 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:45:36 +0400 Subject: [PATCH 033/124] Ask a cold candidate again until it reaches its database /ready waits at most three seconds for the database, while a new connection may take up to five, and the candidate smoke read /ready once. A cold candidate whose first connection was slow therefore failed the whole deployment. When the candidate check requires the database revision and /ready reports the database unreachable, the smoke now asks again, up to SIMULATION_DATABASE_READY_MAX_ATTEMPTS times (six by default), with the usual retry delay. A database that is reachable but at another revision still fails at once. The fake curl in the deployment tests can now serve a sequence of /ready bodies. Co-Authored-By: Claude Opus 5.5 --- .../cloud-run-simulation-entry-smoke.sh | 18 ++++++++ .../DEPLOYMENT.md | 3 +- .../tests/test_deployment_assets_support.py | 14 ++++++ .../test_simulation_database_deployment.py | 46 +++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/.github/scripts/cloud-run-simulation-entry-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh index ee36a32e3..d34aa0de5 100755 --- a/.github/scripts/cloud-run-simulation-entry-smoke.sh +++ b/.github/scripts/cloud-run-simulation-entry-smoke.sh @@ -77,6 +77,9 @@ if [ "${expected_identity}" = "required" ] || fi routing_max_attempts="${SIMULATION_ENTRYPOINT_ROUTING_MAX_ATTEMPTS:-24}" routing_retry_delay_seconds="${SIMULATION_ENTRYPOINT_ROUTING_RETRY_DELAY_SECONDS:-5}" +# A cold candidate may not reach its database within /ready's three-second +# wait; an unreachable database is asked about again this many times. +database_ready_max_attempts="${SIMULATION_DATABASE_READY_MAX_ATTEMPTS:-6}" if ! [[ "${routing_max_attempts}" =~ ^[1-9][0-9]*$ ]]; then printf 'SIMULATION_ENTRYPOINT_ROUTING_MAX_ATTEMPTS must be a positive integer\n' >&2 @@ -86,6 +89,10 @@ if ! [[ "${routing_retry_delay_seconds}" =~ ^[0-9]+$ ]]; then printf 'SIMULATION_ENTRYPOINT_ROUTING_RETRY_DELAY_SECONDS must be a non-negative integer\n' >&2 exit 2 fi +if ! [[ "${database_ready_max_attempts}" =~ ^[1-9][0-9]*$ ]]; then + printf 'SIMULATION_DATABASE_READY_MAX_ATTEMPTS must be a positive integer\n' >&2 + exit 2 +fi health_headers="$(mktemp)" health_body="$(mktemp)" @@ -147,6 +154,17 @@ done ready_body="$(curl --fail --silent --show-error "${base_url}/ready")" jq -e '.status == "ready"' <<<"${ready_body}" >/dev/null +if [ "${require_database_revision}" = "true" ]; then + database_attempt=1 + while [ "${database_attempt}" -lt "${database_ready_max_attempts}" ] && + jq -e '.simulation_database.reachable == false' <<<"${ready_body}" >/dev/null; do + printf '%s: the simulation database was not reached; asking again (%s/%s)\n' \ + "${base_url}" "${database_attempt}" "${database_ready_max_attempts}" >&2 + sleep "${routing_retry_delay_seconds}" + ready_body="$(curl --fail --silent --show-error "${base_url}/ready")" + database_attempt=$((database_attempt + 1)) + done +fi if [ "${require_database_revision}" = "true" ] && ! jq -e ' .simulation_database.reachable == true diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index de36a6b31..027cf74c8 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -49,7 +49,8 @@ read it. Modal never receives either URL. image expects, under `simulation_database`. The database does not change the readiness status, because no old-gateway or Stage 12 route uses it. The candidate smoke check requires the two revisions to match, which shows that the -runtime secret reached the revision and the migration ran; the stable and +runtime secret reached the revision and the migration ran. It asks again, up to +six times, while a cold candidate has not reached its database yet; the stable and rollback checks do not, because an older image may run against a newer schema. The ownership rules and roles are described in `libs/policyengine-simulation-persistence/README.md`. diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py index 7b4748349..00ab839cf 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py @@ -129,6 +129,20 @@ def write_fake_smoke_curl(tmp_path: Path, revisions: list[str]) -> tuple[Path, P ;; */ready) ready_body='{"status":"ready"}' + if [ -n "${FAKE_CURL_READY_BODIES:-}" ]; then + # One body per line, served in order; the last one repeats. + ready_count=0 + if [ -f "${FAKE_CURL_READY_BODIES}.count" ]; then + ready_count="$(cat "${FAKE_CURL_READY_BODIES}.count")" + fi + ready_count=$((ready_count + 1)) + printf '%s' "${ready_count}" > "${FAKE_CURL_READY_BODIES}.count" + line_count="$(wc -l < "${FAKE_CURL_READY_BODIES}")" + if [ "${ready_count}" -gt "${line_count}" ]; then + ready_count="${line_count}" + fi + ready_body="$(sed -n "${ready_count}p" "${FAKE_CURL_READY_BODIES}")" + fi printf '%s' "${FAKE_CURL_READY:-${ready_body}}" ;; */versions) diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py index 374c12c0c..8b1d302cb 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py @@ -325,6 +325,52 @@ def test_the_smoke_rejects_a_database_not_at_the_expected_revision( assert "simulation database" in result.stderr +def _run_smoke_with_ready_bodies(tmp_path, bodies): + bodies_file = tmp_path / "ready-bodies" + bodies_file.write_text("\n".join(bodies) + "\n", encoding="utf-8") + state_file, revisions_file = write_fake_smoke_curl(tmp_path, ["revision-1"]) + environment = smoke_test_env(tmp_path, state_file, revisions_file) | { + "FAKE_CURL_READY_BODIES": str(bodies_file), + "REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION": "true", + "SIMULATION_DATABASE_READY_MAX_ATTEMPTS": "3", + } + result = subprocess.run( + ["bash", SMOKE_SCRIPT, "https://candidate.example.test", "revision-1"], + check=False, + capture_output=True, + text=True, + env=environment, + ) + ready_requests = int(Path(f"{bodies_file}.count").read_text(encoding="utf-8")) + return result, ready_requests + + +def test_the_smoke_waits_for_a_database_a_cold_instance_has_not_reached(tmp_path): + result, ready_requests = _run_smoke_with_ready_bodies( + tmp_path, [_ready_body(None), _ready_body(None), _ready_body("0001")] + ) + + assert result.returncode == 0, result.stderr + assert ready_requests == 3 + + +def test_the_smoke_stops_waiting_for_an_unreachable_database(tmp_path): + result, ready_requests = _run_smoke_with_ready_bodies(tmp_path, [_ready_body(None)]) + + assert result.returncode == 1 + assert ready_requests == 3 + assert "simulation database" in result.stderr + + +def test_the_smoke_does_not_wait_for_a_database_at_another_revision(tmp_path): + result, ready_requests = _run_smoke_with_ready_bodies( + tmp_path, [_ready_body("0001", expected_revision="0002"), _ready_body("0002")] + ) + + assert result.returncode == 1 + assert ready_requests == 1 + + def test_stable_and_rollback_smokes_ignore_the_database_revision(tmp_path): result = _run_smoke(tmp_path, _ready_body(None), require=None) From 5851cb6c7f149c638baadb59a600775cf4ea4ac6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:48:14 +0400 Subject: [PATCH 034/124] Prove the migration-cycle and migrate-before-entrypoint rules reject bad examples Both rules were registered without proof, so the rules page listed them as unproven. - sim.persistence.migration-cycle: migration_cycle_failures runs a chain's upgrade, optional alembic check, downgrade and upgrade, and reports the first step that fails. The chain test uses it with the check, and the proof runs two fixture chains on PostgreSQL: one whose downgrade undoes its upgrade passes, and one whose downgrade leaves its table behind fails on the second upgrade. - sim.persistence.migrate-before-entrypoint: migration_wiring_violations checks a deploy workflow's jobs: the entrypoint waits for a successful migration job, the migrator URL reaches only the migration step, and that step applies revisions only from main. The deploy workflow passes it; the proof's bad fixture fails all three checks and the good fixture passes. Co-Authored-By: Claude Opus 5.5 --- docs/generated/rules.md | 6 +- .../tests/migration_support.py | 40 +++++++++++++ .../tests/test_migration_cycle.py | 28 ++++++++-- .../tests/migration_wiring_checks.py | 56 +++++++++++++++++++ .../test_simulation_database_deployment.py | 37 +++++++++++- .../bad_workflow.yml | 19 +++++++ .../good_workflow.yml | 20 +++++++ .../bad_chain/env.py | 20 +++++++ .../bad_chain/versions/0001_proof_table.py | 21 +++++++ .../good_chain/env.py | 20 +++++++ .../good_chain/versions/0001_proof_table.py | 19 +++++++ rules/registry.toml | 11 ++++ 12 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 projects/policyengine-simulation-entry/tests/migration_wiring_checks.py create mode 100644 rules/fixtures/sim.persistence.migrate-before-entrypoint/bad_workflow.yml create mode 100644 rules/fixtures/sim.persistence.migrate-before-entrypoint/good_workflow.yml create mode 100644 rules/fixtures/sim.persistence.migration-cycle/bad_chain/env.py create mode 100644 rules/fixtures/sim.persistence.migration-cycle/bad_chain/versions/0001_proof_table.py create mode 100644 rules/fixtures/sim.persistence.migration-cycle/good_chain/env.py create mode 100644 rules/fixtures/sim.persistence.migration-cycle/good_chain/versions/0001_proof_table.py diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 6c88ffb38..ade16ec86 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -54,8 +54,6 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.deploy.smoke-tests-strict` - `sim.deploy.traffic-changes-exact` - `sim.deploy.workflow-shell-safety` -- `sim.persistence.migrate-before-entrypoint` -- `sim.persistence.migration-cycle` - `sim.process.rules-registry` - `sim.stage12.deployment-scope` - `sim.stage12.deployment-uses-api-schema` @@ -141,8 +139,8 @@ flowchart LR | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | | --- | --- | --- | --- | --- | --- | --- | --- | | `sim.persistence.executor-independent` | The Modal executor and gateway never declare, lock or import policyengine-simulation-persistence; only the Simulation Entrypoint and the migration job install it. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | -| `sim.persistence.migrate-before-entrypoint` | Each deployment migrates the simulation schema in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys, applying revisions only from main and otherwise requiring the database at the chain's head; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py` | `pr.yml:test` | **unproven** | -| `sim.persistence.migration-cycle` | On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py` | `pr.yml:simulation-database` | **unproven** | +| `sim.persistence.migrate-before-entrypoint` | Each deployment migrates the simulation schema in its own job, the only one that receives SIMULATION_MIGRATION_DATABASE_URL, before the entrypoint candidate deploys, applying revisions only from main and otherwise requiring the database at the chain's head; the entrypoint receives SIMULATION_DATABASE_URL as a pinned Secret Manager version; and the candidate smoke requires the database at the image's revision. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py` | `pr.yml:test` | yes | +| `sim.persistence.migration-cycle` | On a fresh PostgreSQL, the simulation chain upgrades to its head, passes alembic check, downgrades to base and upgrades again, run by a non-superuser migrator, and the runtime role can read the revision but cannot change the schema. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_migration_cycle.py` | `pr.yml:simulation-database` | yes | | `sim.persistence.no-modal-database-credentials` | No Modal secret, Modal deployment or Modal application receives a database URL other than the comparison path's own STAGE12_DATABASE_URL, which Modal may hold until the comparison path is removed; in particular never SIMULATION_DATABASE_URL or SIMULATION_MIGRATION_DATABASE_URL. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | | `sim.persistence.one-migration-chain` | The simulation schema changes only through the one Alembic chain at migrations/, configured by migrations/alembic.ini and versioned in simulation.alembic_version; no other Alembic configuration, environment or revision directory exists. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py` | `pr.yml:test` | yes | diff --git a/libs/policyengine-simulation-persistence/tests/migration_support.py b/libs/policyengine-simulation-persistence/tests/migration_support.py index fde328bed..ca0d2184c 100644 --- a/libs/policyengine-simulation-persistence/tests/migration_support.py +++ b/libs/policyengine-simulation-persistence/tests/migration_support.py @@ -3,9 +3,14 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING +from alembic import command from alembic.config import Config +if TYPE_CHECKING: + from collections.abc import Callable + REPOSITORY_ROOT = Path(__file__).resolve().parents[3] ALEMBIC_CONFIG_PATH = REPOSITORY_ROOT / "migrations" / "alembic.ini" MIGRATION_DATABASE_URL_VARIABLE = "SIMULATION_MIGRATION_DATABASE_URL" @@ -16,3 +21,38 @@ def alembic_config() -> Config: """Return the repository's Alembic configuration.""" return Config(str(ALEMBIC_CONFIG_PATH)) + + +def chain_config(script_location: Path) -> Config: + """Return a configuration for the chain at ``script_location``.""" + config = Config() + config.set_main_option("script_location", str(script_location)) + return config + + +def _step_failure(name: str, step: Callable[[], object]) -> str | None: + try: + step() + except Exception as error: # noqa: BLE001 # reported as the cycle's failure + return f"{name} failed: {type(error).__name__}" + return None + + +def migration_cycle_failures(config: Config, *, check: bool) -> list[str]: + """Upgrade, downgrade and upgrade the chain again; return what failed. + + With ``check``, ``alembic check`` must also find nothing to change after + each upgrade. The cycle stops at the first failure. + """ + steps: list[tuple[str, Callable[[], object]]] = [ + ("upgrade", lambda: command.upgrade(config, "head")) + ] + if check: + steps.append(("check", lambda: command.check(config))) + steps.append(("downgrade", lambda: command.downgrade(config, "base"))) + steps.extend(steps[: 2 if check else 1]) + for name, step in steps: + failure = _step_failure(name, step) + if failure is not None: + return [failure] + return [] diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index ef636033d..5357416ca 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -4,7 +4,13 @@ import pytest from alembic import command -from migration_support import MIGRATOR_ROLE, alembic_config +from migration_support import ( + MIGRATOR_ROLE, + REPOSITORY_ROOT, + alembic_config, + chain_config, + migration_cycle_failures, +) from policyengine_simulation_persistence import ( HEAD_REVISION, SIMULATION_SCHEMA, @@ -36,16 +42,26 @@ def _superuser_engine(database, **options): def test_upgrade_check_downgrade_upgrade_cycle(disposable_database) -> None: config = alembic_config() - command.upgrade(config, "head") + assert migration_cycle_failures(config, check=True) == [] assert _revision(disposable_database.migrator_url) == HEAD_REVISION - command.check(config) command.downgrade(config, "base") assert _revision(disposable_database.migrator_url) is None - command.upgrade(config, "head") - assert _revision(disposable_database.migrator_url) == HEAD_REVISION - command.check(config) + +def test_rule_proof_migration_cycle(disposable_database) -> None: + """Proof for rule sim.persistence.migration-cycle (rules/registry.toml).""" + fixtures = ( + REPOSITORY_ROOT / "rules" / "fixtures" / "sim.persistence.migration-cycle" + ) + + assert ( + migration_cycle_failures(chain_config(fixtures / "good_chain"), check=False) + == [] + ) + assert migration_cycle_failures( + chain_config(fixtures / "bad_chain"), check=False + ) == ["upgrade failed: ProgrammingError"] def test_the_version_table_lives_only_in_the_simulation_schema( diff --git a/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py b/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py new file mode 100644 index 000000000..405451000 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py @@ -0,0 +1,56 @@ +"""The check behind sim.persistence.migrate-before-entrypoint's workflow part. + +It takes a deploy workflow's jobs, so the rule test runs it on the reusable +deploy workflow and the proof test on the rule's fixtures. +""" + +from __future__ import annotations + +import json + +MIGRATION_JOB = "migrate_simulation_database" +MIGRATION_STEP = "Apply simulation schema migrations" +MIGRATION_SECRET = "SIMULATION_MIGRATION_DATABASE_URL" +ENTRYPOINT_JOB = "deploy_entrypoint" +MIGRATION_MODE = "${{ github.ref == 'refs/heads/main' && 'upgrade' || 'verify' }}" + + +def _needs(job: dict) -> list[str]: + needs = job.get("needs", []) + return [needs] if isinstance(needs, str) else list(needs) + + +def _secret_carriers(jobs: dict) -> list[tuple[str, str]]: + carriers = [] + for job_name, job in jobs.items(): + if MIGRATION_SECRET in json.dumps(job.get("env", {})): + carriers.append((job_name, "job env")) + carriers.extend( + (job_name, str(step.get("name"))) + for step in job.get("steps", []) + if MIGRATION_SECRET in json.dumps(step) + ) + return carriers + + +def migration_wiring_violations(jobs: dict) -> list[str]: + """Return how a deploy workflow's jobs depart from the migration wiring.""" + migration = jobs.get(MIGRATION_JOB) + if migration is None: + return [f"no {MIGRATION_JOB} job"] + violations = [] + entrypoint = jobs.get(ENTRYPOINT_JOB, {}) + if MIGRATION_JOB not in _needs(entrypoint) or ( + f"needs.{MIGRATION_JOB}.result == 'success'" not in entrypoint.get("if", "") + ): + violations.append( + f"{ENTRYPOINT_JOB} does not wait for a successful {MIGRATION_JOB}" + ) + carriers = _secret_carriers(jobs) + if carriers != [(MIGRATION_JOB, MIGRATION_STEP)]: + violations.append(f"{MIGRATION_SECRET} reaches {carriers}") + steps = {step.get("name"): step for step in migration.get("steps", [])} + mode = steps.get(MIGRATION_STEP, {}).get("env", {}).get("SIMULATION_MIGRATION_MODE") + if mode != MIGRATION_MODE: + violations.append(f"{MIGRATION_STEP} applies revisions from any branch") + return violations diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py index 8b1d302cb..119605cac 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py @@ -13,6 +13,8 @@ from pathlib import Path import pytest +import yaml +from migration_wiring_checks import migration_wiring_violations from test_deployment_assets_cloud_run import cloud_run_deploy_environment from test_deployment_assets_support import ( CLOUD_RUN_DEPLOY_SCRIPT, @@ -23,7 +25,12 @@ workflow_jobs, write_fake_smoke_curl, ) -from workflow_script_support import WORKFLOWS_DIR, FakeCommands, run_script +from workflow_script_support import ( + REPOSITORY_ROOT, + WORKFLOWS_DIR, + FakeCommands, + run_script, +) MIGRATION_SCRIPT = "migrate-simulation-database.sh" MIGRATION_JOB = "migrate_simulation_database" @@ -104,6 +111,34 @@ def test_only_the_migration_step_receives_the_migrator_url(): ] +def test_the_deploy_workflow_follows_the_migration_wiring(): + assert migration_wiring_violations(workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)) == [] + + +def test_rule_proof_migrate_before_entrypoint(): + """Proof for rule sim.persistence.migrate-before-entrypoint.""" + fixtures = ( + REPOSITORY_ROOT + / "rules" + / "fixtures" + / ("sim.persistence.migrate-before-entrypoint") + ) + + bad = migration_wiring_violations( + yaml.safe_load((fixtures / "bad_workflow.yml").read_text(encoding="utf-8"))[ + "jobs" + ] + ) + good = migration_wiring_violations( + yaml.safe_load((fixtures / "good_workflow.yml").read_text(encoding="utf-8"))[ + "jobs" + ] + ) + + assert len(bad) == 3 + assert good == [] + + UV_ALEMBIC = ["uv", "run", "--frozen", "--extra", "migrations", "alembic", "-c", "../../migrations/alembic.ini"] # fmt: skip diff --git a/rules/fixtures/sim.persistence.migrate-before-entrypoint/bad_workflow.yml b/rules/fixtures/sim.persistence.migrate-before-entrypoint/bad_workflow.yml new file mode 100644 index 000000000..f6f66bd65 --- /dev/null +++ b/rules/fixtures/sim.persistence.migrate-before-entrypoint/bad_workflow.yml @@ -0,0 +1,19 @@ +# Proof fixture: the entrypoint deploys without waiting for the migration, +# the migrator URL reaches every step of the job, and revisions are applied +# from whatever branch dispatched the deployment. +name: Bad deployment +on: workflow_dispatch +jobs: + migrate_simulation_database: + runs-on: ubuntu-latest + env: + SIMULATION_MIGRATION_DATABASE_URL: ${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }} + steps: + - name: Apply simulation schema migrations + env: + SIMULATION_MIGRATION_MODE: upgrade + run: ../../.github/scripts/migrate-simulation-database.sh + deploy_entrypoint: + runs-on: ubuntu-latest + steps: + - run: .github/scripts/deploy-cloud-run-simulation-entry.sh diff --git a/rules/fixtures/sim.persistence.migrate-before-entrypoint/good_workflow.yml b/rules/fixtures/sim.persistence.migrate-before-entrypoint/good_workflow.yml new file mode 100644 index 000000000..c45ddcaf2 --- /dev/null +++ b/rules/fixtures/sim.persistence.migrate-before-entrypoint/good_workflow.yml @@ -0,0 +1,20 @@ +# Proof fixture: the migration job alone receives the migrator URL, in its +# migration step; it applies revisions only from main; and the entrypoint +# deploys only after it succeeded. +name: Good deployment +on: workflow_dispatch +jobs: + migrate_simulation_database: + runs-on: ubuntu-latest + steps: + - name: Apply simulation schema migrations + env: + SIMULATION_MIGRATION_DATABASE_URL: ${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }} + SIMULATION_MIGRATION_MODE: ${{ github.ref == 'refs/heads/main' && 'upgrade' || 'verify' }} + run: ../../.github/scripts/migrate-simulation-database.sh + deploy_entrypoint: + if: ${{ always() && needs.migrate_simulation_database.result == 'success' }} + needs: [migrate_simulation_database] + runs-on: ubuntu-latest + steps: + - run: .github/scripts/deploy-cloud-run-simulation-entry.sh diff --git a/rules/fixtures/sim.persistence.migration-cycle/bad_chain/env.py b/rules/fixtures/sim.persistence.migration-cycle/bad_chain/env.py new file mode 100644 index 000000000..c9aa31716 --- /dev/null +++ b/rules/fixtures/sim.persistence.migration-cycle/bad_chain/env.py @@ -0,0 +1,20 @@ +"""Proof fixture environment: applies this chain to the migration database.""" + +import os + +from alembic import context +from sqlalchemy import create_engine + +url = os.environ["SIMULATION_MIGRATION_DATABASE_URL"].replace( + "postgresql://", "postgresql+psycopg://", 1 +) +engine = create_engine(url) +with engine.begin() as connection: + context.configure( + connection=connection, + target_metadata=None, + version_table="proof_bad_chain_version", + ) + with context.begin_transaction(): + context.run_migrations() +engine.dispose() diff --git a/rules/fixtures/sim.persistence.migration-cycle/bad_chain/versions/0001_proof_table.py b/rules/fixtures/sim.persistence.migration-cycle/bad_chain/versions/0001_proof_table.py new file mode 100644 index 000000000..7b0dde780 --- /dev/null +++ b/rules/fixtures/sim.persistence.migration-cycle/bad_chain/versions/0001_proof_table.py @@ -0,0 +1,21 @@ +"""Proof fixture: a revision whose downgrade leaves its table behind. + +Upgrading again after the downgrade fails, because the table still exists. +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the proof table.""" + op.create_table("proof_bad_table", sa.Column("id", sa.Integer(), primary_key=True)) + + +def downgrade() -> None: + """Leave the proof table behind, which the cycle must catch.""" diff --git a/rules/fixtures/sim.persistence.migration-cycle/good_chain/env.py b/rules/fixtures/sim.persistence.migration-cycle/good_chain/env.py new file mode 100644 index 000000000..f2a7f3950 --- /dev/null +++ b/rules/fixtures/sim.persistence.migration-cycle/good_chain/env.py @@ -0,0 +1,20 @@ +"""Proof fixture environment: applies this chain to the migration database.""" + +import os + +from alembic import context +from sqlalchemy import create_engine + +url = os.environ["SIMULATION_MIGRATION_DATABASE_URL"].replace( + "postgresql://", "postgresql+psycopg://", 1 +) +engine = create_engine(url) +with engine.begin() as connection: + context.configure( + connection=connection, + target_metadata=None, + version_table="proof_good_chain_version", + ) + with context.begin_transaction(): + context.run_migrations() +engine.dispose() diff --git a/rules/fixtures/sim.persistence.migration-cycle/good_chain/versions/0001_proof_table.py b/rules/fixtures/sim.persistence.migration-cycle/good_chain/versions/0001_proof_table.py new file mode 100644 index 000000000..06869afa1 --- /dev/null +++ b/rules/fixtures/sim.persistence.migration-cycle/good_chain/versions/0001_proof_table.py @@ -0,0 +1,19 @@ +"""Proof fixture: a revision whose downgrade undoes its upgrade.""" + +import sqlalchemy as sa +from alembic import op + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the proof table.""" + op.create_table("proof_good_table", sa.Column("id", sa.Integer(), primary_key=True)) + + +def downgrade() -> None: + """Drop the proof table.""" + op.drop_table("proof_good_table") diff --git a/rules/registry.toml b/rules/registry.toml index b5acf71ca..c0d0662b2 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -183,6 +183,11 @@ nodes = [ workflow = ".github/workflows/pr.yml" job = "simulation-database" invocation = "uv run --frozen pytest tests/ -v -m postgres" +[rule.proof] +bad_fixture = "rules/fixtures/sim.persistence.migration-cycle/bad_chain/versions/0001_proof_table.py" +good_fixture = "rules/fixtures/sim.persistence.migration-cycle/good_chain/versions/0001_proof_table.py" +project = "libs/policyengine-simulation-persistence" +test = "tests/test_migration_cycle.py::test_rule_proof_migration_cycle" [[rule]] id = "sim.persistence.executor-independent" @@ -249,6 +254,7 @@ autofix = false kind = "pytest" project = "projects/policyengine-simulation-entry" nodes = [ + "tests/test_simulation_database_deployment.py::test_the_deploy_workflow_follows_the_migration_wiring", "tests/test_simulation_database_deployment.py::test_the_migration_job_runs_before_the_entrypoint_deploys", "tests/test_simulation_database_deployment.py::test_only_the_migration_step_receives_the_migrator_url", "tests/test_simulation_database_deployment.py::test_only_the_default_branch_applies_migrations", @@ -259,6 +265,11 @@ nodes = [ workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.persistence.migrate-before-entrypoint/bad_workflow.yml" +good_fixture = "rules/fixtures/sim.persistence.migrate-before-entrypoint/good_workflow.yml" +project = "projects/policyengine-simulation-entry" +test = "tests/test_simulation_database_deployment.py::test_rule_proof_migrate_before_entrypoint" # --- Stage 12 persistence -------------------------------------------------- From 3795e1917928a5c6cb7727358a12d454620bedbe Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:50:19 +0400 Subject: [PATCH 035/124] Fail a migration that waits more than ten seconds for a lock Each migration runs in one transaction, and adding a foreign key to a Public API table (the next revisions do) takes a lock on that table. A migration waiting behind a long Public API transaction would hold its place in the lock queue, and every Public API write to that table would queue behind it until the migration got its lock. The migration environment now sets lock_timeout to ten seconds for its transaction, so such a migration fails, applies nothing, and stops the deploy; the next deploy tries again. A PostgreSQL test holds a lock the migration needs and checks that the upgrade fails with a lock timeout instead of waiting. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 5 +++- .../tests/test_migration_cycle.py | 24 ++++++++++++++++++- migrations/env.py | 7 +++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index be8240e3a..79d80d5f7 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -87,7 +87,10 @@ this code knows. chain's upgrade, `alembic check`, downgrade and upgrade cycle. The schema creation and every pending revision apply in one transaction, so a -failed migration leaves nothing applied. The previous release keeps serving +failed migration leaves nothing applied. A statement that waits more than ten +seconds for a lock fails the migration instead of waiting longer, because +adding a foreign key locks the Public API table it refers to and the Public +API's writes would queue behind it; the next deployment tries again. The previous release keeps serving traffic while a deployment migrates, and a rollback moves traffic back to an older image without downgrading the schema, so every revision must work with the code of the release before it: add columns and tables, and remove them diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index 5357416ca..90bc44ea1 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -2,6 +2,8 @@ from __future__ import annotations +import time + import pytest from alembic import command from migration_support import ( @@ -18,7 +20,7 @@ read_schema_revision, ) from sqlalchemy import create_engine, inspect, text -from sqlalchemy.exc import ProgrammingError +from sqlalchemy.exc import OperationalError, ProgrammingError pytestmark = pytest.mark.postgres @@ -180,3 +182,23 @@ def fail_after_creating_the_schema(*_arguments, **_keywords): assert not inspect(connection).has_schema(SIMULATION_SCHEMA) finally: engine.dispose() + + +def test_a_migration_waiting_for_a_lock_gives_up_instead_of_blocking( + disposable_database, +) -> None: + """Writers queue behind a waiting migration, so it must not wait long.""" + command.upgrade(alembic_config(), "head") + holder = _superuser_engine(disposable_database) + try: + with holder.begin() as connection: + connection.execute( + text("LOCK TABLE simulation.alembic_version IN ACCESS EXCLUSIVE MODE") + ) + started = time.monotonic() + with pytest.raises(OperationalError, match="lock timeout"): + command.upgrade(alembic_config(), "head") + waited = time.monotonic() - started + finally: + holder.dispose() + assert waited < 30 diff --git a/migrations/env.py b/migrations/env.py index c321abeca..b024e1d89 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -13,7 +13,7 @@ normalized_database_url, simulation_metadata, ) -from sqlalchemy import create_engine, inspect, pool +from sqlalchemy import create_engine, inspect, pool, text from sqlalchemy.schema import CreateSchema if TYPE_CHECKING: @@ -21,6 +21,10 @@ DATABASE_URL_VARIABLE = "SIMULATION_MIGRATION_DATABASE_URL" CONNECT_TIMEOUT_SECONDS = 10 +# How long a migration statement waits for a lock before the migration fails. +# Adding a foreign key locks the Public API table it refers to, and the Public +# API's writes queue behind a migration that waits, so it must not wait long. +LOCK_TIMEOUT = "10s" config = context.config if config.config_file_name is not None: @@ -58,6 +62,7 @@ def _ensure_simulation_schema(connection: Connection) -> None: def _run(connection: Connection) -> None: + connection.execute(text(f"SET LOCAL lock_timeout = '{LOCK_TIMEOUT}'")) # The version table lives in the simulation schema, so the schema must # exist before Alembic looks for it. _ensure_simulation_schema(connection) From 997268d3439ffcd276eeae34089774217de7dd4a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:09:04 +0400 Subject: [PATCH 036/124] Prove the workflow-shell-safety and container-context-minimal rules This branch tightens both rules, which had no proof: - sim.deploy.workflow-shell-safety now also requires every runtime secret to be resolved in its own assignment. combined_secret_resolutions finds a Bash array assignment that resolves more than one secret, where an earlier failure would pass unnoticed. The deploy script passes it (a new test, registered under the rule); main's script before this branch fails it, and so does the proof's bad fixture. - sim.deploy.container-context-minimal now also requires every local library the entrypoint installs to be in its build context. libraries_missing_from_context compares a project's path sources with its .dockerignore; the existing test uses it, and the proof's bad fixture leaves one library out. Co-Authored-By: Claude Opus 5.5 --- docs/generated/rules.md | 6 +- .../tests/deployment_rule_checks.py | 55 +++++++++++++++++++ .../tests/test_deployment_assets_cloud_run.py | 26 +++++++++ .../tests/test_deployment_assets_traffic.py | 50 ++++++++++------- .../bad.dockerignore | 4 ++ .../good.dockerignore | 6 ++ .../pyproject.toml | 9 +++ .../bad_deploy_script.sh | 9 +++ .../good_deploy_script.sh | 16 ++++++ rules/registry.toml | 11 ++++ 10 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 projects/policyengine-simulation-entry/tests/deployment_rule_checks.py create mode 100644 rules/fixtures/sim.deploy.container-context-minimal/bad.dockerignore create mode 100644 rules/fixtures/sim.deploy.container-context-minimal/good.dockerignore create mode 100644 rules/fixtures/sim.deploy.container-context-minimal/pyproject.toml create mode 100644 rules/fixtures/sim.deploy.workflow-shell-safety/bad_deploy_script.sh create mode 100644 rules/fixtures/sim.deploy.workflow-shell-safety/good_deploy_script.sh diff --git a/docs/generated/rules.md b/docs/generated/rules.md index ade16ec86..5d470dda9 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -42,7 +42,6 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.contracts.clients-publish-deployed-commit` - `sim.contracts.full-integration-beta-only` - `sim.contracts.gateway-openapi-golden` -- `sim.deploy.container-context-minimal` - `sim.deploy.gcloud-without-terraform` - `sim.deploy.numerical-sentinel-gate` - `sim.deploy.permission-enforcement-wired` @@ -53,7 +52,6 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.deploy.shell-scripts-valid` - `sim.deploy.smoke-tests-strict` - `sim.deploy.traffic-changes-exact` -- `sim.deploy.workflow-shell-safety` - `sim.process.rules-registry` - `sim.stage12.deployment-scope` - `sim.stage12.deployment-uses-api-schema` @@ -170,7 +168,7 @@ flowchart LR | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | | --- | --- | --- | --- | --- | --- | --- | --- | -| `sim.deploy.container-context-minimal` | Container builds exclude local environments and unrelated projects but include every local library the entrypoint installs, and the entrypoint container builds for the requested target platform. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.container-context-minimal` | Container builds exclude local environments and unrelated projects but include every local library the entrypoint installs, and the entrypoint container builds for the requested target platform. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | yes | | `sim.deploy.docker-images-build` | The simulation entry and executor Docker images build. | mechanical | enforced | yes | CI job `docker-build` | `pr.yml:docker-build` | — | | `sim.deploy.gcloud-without-terraform` | Deployment uses the gcloud-based workflow, not Terraform. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.image-smoke-covers-every-image-lib` | The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. | mechanical | doc-only | no | nothing | — | — | @@ -184,7 +182,7 @@ flowchart LR | `sim.deploy.shell-scripts-valid` | Deployment shell scripts have valid shell syntax. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.smoke-tests-strict` | Candidate and stable smoke tests reject a revision mismatch without retrying, and fail if the previous revision does not converge. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.traffic-changes-exact` | Traffic changes target an exact, validated revision, are reversible, and refuse to run over an intervening promotion. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** | -| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, stops before deploying when any runtime secret cannot be resolved, and resolves the Stage 12 secrets with the Stage 12 block's own helper. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, pins secret versions, stops before deploying when any runtime secret cannot be resolved, and resolves the Stage 12 secrets with the Stage 12 block's own helper. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | yes | ```mermaid flowchart LR diff --git a/projects/policyengine-simulation-entry/tests/deployment_rule_checks.py b/projects/policyengine-simulation-entry/tests/deployment_rule_checks.py new file mode 100644 index 000000000..fa06bed25 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/deployment_rule_checks.py @@ -0,0 +1,55 @@ +"""Checks behind two deployment rules, runnable on the repository or a fixture. + +- ``combined_secret_resolutions`` (sim.deploy.workflow-shell-safety): Bash + gives an array assignment the exit status of its last command substitution + only, so an assignment that resolves several secrets lets an earlier + failure pass unnoticed. +- ``libraries_missing_from_context`` (sim.deploy.container-context-minimal): + every local library the entrypoint installs must be in its build context. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import tomllib + +ARRAY_ASSIGNMENT = re.compile(r"^\s*\w+\+?=\(", re.MULTILINE) +# The line that closes a multi-line array assignment: only its parenthesis. +ARRAY_END = re.compile(r"\n[ \t]*\)") +SECRET_RESOLUTION = "$(resolve_secret_reference" + + +def combined_secret_resolutions(script_text: str) -> list[str]: + """Return array assignments that resolve more than one secret.""" + violations = [] + for match in ARRAY_ASSIGNMENT.finditer(script_text): + line_end = script_text.find("\n", match.end()) + first_line = script_text[match.start() : line_end if line_end >= 0 else None] + if ")" in first_line: + assignment = first_line + else: + closing = ARRAY_END.search(script_text, match.end()) + assignment = script_text[ + match.start() : closing.start() if closing is not None else None + ] + if assignment.count(SECRET_RESOLUTION) > 1: + violations.append(assignment.strip().splitlines()[0]) + return violations + + +def libraries_missing_from_context(pyproject_text: str, ignore_text: str) -> list[str]: + """Return the local libraries the project installs but its context leaves out.""" + rules = ignore_text.splitlines() + sources = ( + tomllib.loads(pyproject_text).get("tool", {}).get("uv", {}).get("sources", {}) + ) + libraries = [ + Path(source["path"]).name for source in sources.values() if "path" in source + ] + return [ + library + for library in libraries + if f"!libs/{library}/" not in rules or f"!libs/{library}/**" not in rules + ] diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py index a6db99073..a6d870841 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py @@ -10,10 +10,12 @@ from pathlib import Path import pytest +from deployment_rule_checks import SECRET_RESOLUTION, combined_secret_resolutions from test_deployment_assets_support import ( CLOUD_RUN_DEPLOY_SCRIPT, DEPLOY_WORKFLOW, DOCKERFILE, + REPOSITORY_ROOT, REUSABLE_DEPLOY_WORKFLOW, STAGE12_DEPLOY_WORKFLOW, ) @@ -193,6 +195,30 @@ def test_cloud_run_deployment_stops_when_a_runtime_secret_cannot_be_resolved( assert not Path(command_environment["GCLOUD_ARGUMENTS_CAPTURE"]).exists() +def test_cloud_run_deployment_resolves_each_secret_in_its_own_assignment(): + script = Path(CLOUD_RUN_DEPLOY_SCRIPT).read_text(encoding="utf-8") + + assert SECRET_RESOLUTION in script + assert combined_secret_resolutions(script) == [] + + +def test_rule_proof_workflow_shell_safety(): + """Proof for rule sim.deploy.workflow-shell-safety (rules/registry.toml).""" + fixtures = ( + REPOSITORY_ROOT / "rules" / "fixtures" / ("sim.deploy.workflow-shell-safety") + ) + + assert combined_secret_resolutions( + (fixtures / "bad_deploy_script.sh").read_text(encoding="utf-8") + ) == ["runtime_secrets=("] + assert ( + combined_secret_resolutions( + (fixtures / "good_deploy_script.sh").read_text(encoding="utf-8") + ) + == [] + ) + + def test_stage12_secrets_are_resolved_by_the_stage12_block_alone(): """The comparison path shares no deploy helper with the main path.""" script = Path(CLOUD_RUN_DEPLOY_SCRIPT).read_text(encoding="utf-8") diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py index 68508c896..df2e4e669 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py @@ -4,9 +4,9 @@ import os import textwrap -from pathlib import Path import tomllib +from deployment_rule_checks import libraries_missing_from_context from test_deployment_assets_support import ( DOCKERIGNORE, GITIGNORE, @@ -174,25 +174,37 @@ def test_container_context_excludes_local_environments_and_unrelated_projects(): def test_container_context_includes_every_local_library_the_entrypoint_installs(): - ignore_rules = DOCKERIGNORE.read_text(encoding="utf-8").splitlines() - pyproject = tomllib.loads( - ( - REPOSITORY_ROOT - / "projects" - / "policyengine-simulation-entry" - / "pyproject.toml" - ).read_text(encoding="utf-8") + entry_project = REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" + pyproject = (entry_project / "pyproject.toml").read_text(encoding="utf-8") + + assert "path = " in pyproject + assert ( + libraries_missing_from_context( + pyproject, DOCKERIGNORE.read_text(encoding="utf-8") + ) + == [] + ) + + +def test_rule_proof_container_context_minimal(): + """Proof for rule sim.deploy.container-context-minimal (rules/registry.toml).""" + fixtures = ( + REPOSITORY_ROOT + / "rules" + / "fixtures" + / ("sim.deploy.container-context-minimal") + ) + pyproject = (fixtures / "pyproject.toml").read_text(encoding="utf-8") + + assert libraries_missing_from_context( + pyproject, (fixtures / "bad.dockerignore").read_text(encoding="utf-8") + ) == ["fixture-persistence"] + assert ( + libraries_missing_from_context( + pyproject, (fixtures / "good.dockerignore").read_text(encoding="utf-8") + ) + == [] ) - library_paths = [ - Path(source["path"]).name - for source in pyproject["tool"]["uv"]["sources"].values() - if "path" in source - ] - - assert library_paths - for library in library_paths: - assert f"!libs/{library}/" in ignore_rules, library - assert f"!libs/{library}/**" in ignore_rules, library def test_production_lock_contains_the_direct_stage12_database_runtime(): diff --git a/rules/fixtures/sim.deploy.container-context-minimal/bad.dockerignore b/rules/fixtures/sim.deploy.container-context-minimal/bad.dockerignore new file mode 100644 index 000000000..d8ed8cfbb --- /dev/null +++ b/rules/fixtures/sim.deploy.container-context-minimal/bad.dockerignore @@ -0,0 +1,4 @@ +# Proof fixture: the context leaves out one library the project installs. +libs/* +!libs/fixture-contract/ +!libs/fixture-contract/** diff --git a/rules/fixtures/sim.deploy.container-context-minimal/good.dockerignore b/rules/fixtures/sim.deploy.container-context-minimal/good.dockerignore new file mode 100644 index 000000000..d80779a02 --- /dev/null +++ b/rules/fixtures/sim.deploy.container-context-minimal/good.dockerignore @@ -0,0 +1,6 @@ +# Proof fixture: the context includes every library the project installs. +libs/* +!libs/fixture-contract/ +!libs/fixture-contract/** +!libs/fixture-persistence/ +!libs/fixture-persistence/** diff --git a/rules/fixtures/sim.deploy.container-context-minimal/pyproject.toml b/rules/fixtures/sim.deploy.container-context-minimal/pyproject.toml new file mode 100644 index 000000000..aa2c53cc7 --- /dev/null +++ b/rules/fixtures/sim.deploy.container-context-minimal/pyproject.toml @@ -0,0 +1,9 @@ +# Proof fixture: a project that installs two local libraries by path. +[project] +name = "fixture-entrypoint" +version = "0.1.0" +dependencies = ["fixture-contract", "fixture-persistence"] + +[tool.uv.sources] +fixture-contract = { path = "../../libs/fixture-contract", editable = true } +fixture-persistence = { path = "../../libs/fixture-persistence", editable = true } diff --git a/rules/fixtures/sim.deploy.workflow-shell-safety/bad_deploy_script.sh b/rules/fixtures/sim.deploy.workflow-shell-safety/bad_deploy_script.sh new file mode 100644 index 000000000..73d926095 --- /dev/null +++ b/rules/fixtures/sim.deploy.workflow-shell-safety/bad_deploy_script.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Proof fixture: one array assignment resolves two secrets, so a failure to +# resolve the first is ignored and the deploy goes on with an empty reference. +set -euo pipefail + +runtime_secrets=( + "OLD_GATEWAY_AUTH_CLIENT_SECRET=$(resolve_secret_reference "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}")" + "SIMULATION_DATABASE_URL=$(resolve_secret_reference "${SIMULATION_DATABASE_URL_SECRET_NAME}")" +) diff --git a/rules/fixtures/sim.deploy.workflow-shell-safety/good_deploy_script.sh b/rules/fixtures/sim.deploy.workflow-shell-safety/good_deploy_script.sh new file mode 100644 index 000000000..6edd73590 --- /dev/null +++ b/rules/fixtures/sim.deploy.workflow-shell-safety/good_deploy_script.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Proof fixture: each secret is resolved in its own assignment, so set -e stops +# the script at the first failure. +set -euo pipefail + +runtime_secrets=() + +add_runtime_secret() { + local variable_name="$1" + local resolved_reference + resolved_reference="$(resolve_secret_reference "$2")" + runtime_secrets+=("${variable_name}=${resolved_reference}") +} + +add_runtime_secret OLD_GATEWAY_AUTH_CLIENT_SECRET "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}" +add_runtime_secret SIMULATION_DATABASE_URL "${SIMULATION_DATABASE_URL_SECRET_NAME}" diff --git a/rules/registry.toml b/rules/registry.toml index c0d0662b2..509cf7cbb 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -450,12 +450,18 @@ nodes = [ "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_does_not_interpolate_github_values_in_shell", "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_escapes_environment_and_pins_secret_versions", "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_stops_when_a_runtime_secret_cannot_be_resolved", + "tests/test_deployment_assets_cloud_run.py::test_cloud_run_deployment_resolves_each_secret_in_its_own_assignment", "tests/test_deployment_assets_cloud_run.py::test_stage12_secrets_are_resolved_by_the_stage12_block_alone", ] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.deploy.workflow-shell-safety/bad_deploy_script.sh" +good_fixture = "rules/fixtures/sim.deploy.workflow-shell-safety/good_deploy_script.sh" +project = "projects/policyengine-simulation-entry" +test = "tests/test_deployment_assets_cloud_run.py::test_rule_proof_workflow_shell_safety" [[rule]] id = "sim.deploy.revision-tags-valid" @@ -673,6 +679,11 @@ nodes = [ workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[rule.proof] +bad_fixture = "rules/fixtures/sim.deploy.container-context-minimal/bad.dockerignore" +good_fixture = "rules/fixtures/sim.deploy.container-context-minimal/good.dockerignore" +project = "projects/policyengine-simulation-entry" +test = "tests/test_deployment_assets_traffic.py::test_rule_proof_container_context_minimal" [[rule]] id = "sim.deploy.docker-images-build" From d121830883c1fe881890b7565d049c3ddff2d682 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:10:40 +0400 Subject: [PATCH 037/124] Close two gaps in the ownership checks and show why a migration cycle failed - The Modal credential check counted only `modal secret create` and `modal deploy` as reaching Modal. A step that starts a temporary app with `modal run` (the precompute and smoke scripts do) was not examined; it is now. The current workflows still pass. - The migrator-URL checks read job and step env but not workflow-level env, which reaches every job. secret_carriers now counts it, and the deployment test uses the same scan instead of its own copy. - migration_cycle_failures reported only the exception class, so a CI failure showed neither the database error nor the detected differences. It now adds the error's first line. Co-Authored-By: Claude Opus 5.5 --- .../tests/migration_support.py | 5 +- .../tests/ownership_checks.py | 9 ++-- .../tests/test_migration_cycle.py | 5 +- .../tests/test_ownership_boundaries.py | 14 ++++++ .../tests/migration_wiring_checks.py | 17 +++++-- .../test_simulation_database_deployment.py | 47 ++++++++++++++----- rules/registry.toml | 1 + 7 files changed, 78 insertions(+), 20 deletions(-) diff --git a/libs/policyengine-simulation-persistence/tests/migration_support.py b/libs/policyengine-simulation-persistence/tests/migration_support.py index ca0d2184c..9f1d02faa 100644 --- a/libs/policyengine-simulation-persistence/tests/migration_support.py +++ b/libs/policyengine-simulation-persistence/tests/migration_support.py @@ -34,7 +34,10 @@ def _step_failure(name: str, step: Callable[[], object]) -> str | None: try: step() except Exception as error: # noqa: BLE001 # reported as the cycle's failure - return f"{name} failed: {type(error).__name__}" + detail = str(error).strip().splitlines() + return f"{name} failed: {type(error).__name__}" + ( + f": {detail[0]}" if detail else "" + ) return None diff --git a/libs/policyengine-simulation-persistence/tests/ownership_checks.py b/libs/policyengine-simulation-persistence/tests/ownership_checks.py index 276cd3e60..6ae57384a 100644 --- a/libs/policyengine-simulation-persistence/tests/ownership_checks.py +++ b/libs/policyengine-simulation-persistence/tests/ownership_checks.py @@ -42,7 +42,9 @@ {"STAGE12_DATABASE_URL", "STAGE12_DATABASE_URL_SECRET_NAME"} ) MODAL_SECRET_COMMAND = re.compile(r"\bmodal\s+secret\s+create\b") -MODAL_DEPLOY_COMMAND = re.compile(r"\bmodal\s+deploy\b") +# Deploying an application or running a temporary one both start Modal +# containers that a step's values may reach. +MODAL_DEPLOY_COMMAND = re.compile(r"\bmodal\s+(?:deploy|run)\b") def _repository_files(root: Path, pattern: str) -> Iterator[Path]: @@ -237,8 +239,9 @@ def modal_credential_violations( """Return Modal secret or deploy steps that carry a foreign database URL. ``workflow_texts`` and ``script_texts`` map a file name to its contents. A - step counts as giving Modal a value when it creates a Modal secret or - deploys a Modal application, directly or through a script that does, and + step counts as giving Modal a value when it creates a Modal secret, deploys + a Modal application or runs a temporary one, directly or through a script + that does, and every value in its step, job or workflow environment reaches it. Only the comparison path's own database URL may reach Modal. """ diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index 90bc44ea1..0772c5a24 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -63,7 +63,10 @@ def test_rule_proof_migration_cycle(disposable_database) -> None: ) assert migration_cycle_failures( chain_config(fixtures / "bad_chain"), check=False - ) == ["upgrade failed: ProgrammingError"] + ) == [ + "upgrade failed: ProgrammingError: (psycopg.errors.DuplicateTable) relation " + '"proof_bad_table" already exists' + ] def test_the_version_table_lives_only_in_the_simulation_schema( diff --git a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py index 5215f8905..eb66ec159 100644 --- a/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py +++ b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py @@ -162,3 +162,17 @@ def test_modal_sources_may_name_only_the_comparison_paths_database_url() -> None assert modal_source_violations({"worker.py": "os.getenv('DATABASE_URL')"}) == [ "worker.py names DATABASE_URL" ] + + +def test_a_temporary_modal_app_may_not_receive_another_database_url() -> None: + workflow = ( + "jobs:\n" + " precompute:\n" + " steps:\n" + " - name: Precompute\n" + " env:\n" + " SIMULATION_DATABASE_URL: x\n" + " run: uv run --frozen modal run src/modal/precompute_app.py\n" + ) + + assert modal_credential_violations({"precompute.yml": workflow}, {}) != [] diff --git a/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py b/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py index 405451000..8b899767d 100644 --- a/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py +++ b/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py @@ -20,8 +20,17 @@ def _needs(job: dict) -> list[str]: return [needs] if isinstance(needs, str) else list(needs) -def _secret_carriers(jobs: dict) -> list[tuple[str, str]]: +def secret_carriers( + jobs: dict, *, workflow_env: dict | None = None +) -> list[tuple[str, str]]: + """Return each job, job env or step that holds the migrator URL. + + A workflow-level ``env:`` reaches every job, so it counts as a carrier + for all of them. + """ carriers = [] + if MIGRATION_SECRET in json.dumps(workflow_env or {}): + carriers.append(("workflow", "workflow env")) for job_name, job in jobs.items(): if MIGRATION_SECRET in json.dumps(job.get("env", {})): carriers.append((job_name, "job env")) @@ -33,7 +42,9 @@ def _secret_carriers(jobs: dict) -> list[tuple[str, str]]: return carriers -def migration_wiring_violations(jobs: dict) -> list[str]: +def migration_wiring_violations( + jobs: dict, *, workflow_env: dict | None = None +) -> list[str]: """Return how a deploy workflow's jobs depart from the migration wiring.""" migration = jobs.get(MIGRATION_JOB) if migration is None: @@ -46,7 +57,7 @@ def migration_wiring_violations(jobs: dict) -> list[str]: violations.append( f"{ENTRYPOINT_JOB} does not wait for a successful {MIGRATION_JOB}" ) - carriers = _secret_carriers(jobs) + carriers = secret_carriers(jobs, workflow_env=workflow_env) if carriers != [(MIGRATION_JOB, MIGRATION_STEP)]: violations.append(f"{MIGRATION_SECRET} reaches {carriers}") steps = {step.get("name"): step for step in migration.get("steps", [])} diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py index 119605cac..ee6486c0e 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py @@ -14,7 +14,7 @@ import pytest import yaml -from migration_wiring_checks import migration_wiring_violations +from migration_wiring_checks import migration_wiring_violations, secret_carriers from test_deployment_assets_cloud_run import cloud_run_deploy_environment from test_deployment_assets_support import ( CLOUD_RUN_DEPLOY_SCRIPT, @@ -90,17 +90,18 @@ def test_the_migration_job_applies_the_chain_with_the_migrator_url_alone(): assert "permissions" not in migration +def _workflow(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + def test_only_the_migration_step_receives_the_migrator_url(): - carriers = [] - for workflow in sorted(WORKFLOWS_DIR.glob("*.yml")): - for job_name, job in workflow_jobs(workflow).items(): - if MIGRATION_SECRET in json.dumps(job.get("env", {})): - carriers.append((workflow.name, job_name, "job env")) - carriers.extend( - (workflow.name, job_name, step.get("name")) - for step in job.get("steps", []) - if MIGRATION_SECRET in json.dumps(step) - ) + carriers = [ + (path.name, *carrier) + for path in sorted(WORKFLOWS_DIR.glob("*.yml")) + for carrier in secret_carriers( + _workflow(path)["jobs"], workflow_env=_workflow(path).get("env") + ) + ] assert carriers == [ ( @@ -112,7 +113,29 @@ def test_only_the_migration_step_receives_the_migrator_url(): def test_the_deploy_workflow_follows_the_migration_wiring(): - assert migration_wiring_violations(workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)) == [] + workflow = _workflow(REUSABLE_DEPLOY_WORKFLOW) + + assert ( + migration_wiring_violations(workflow["jobs"], workflow_env=workflow.get("env")) + == [] + ) + + +def test_the_migrator_url_may_not_reach_every_job_through_workflow_env(): + workflow = yaml.safe_load( + ( + REPOSITORY_ROOT + / "rules" + / "fixtures" + / "sim.persistence.migrate-before-entrypoint" + / "good_workflow.yml" + ).read_text(encoding="utf-8") + ) + workflow["env"] = { + MIGRATION_SECRET: "${{ secrets.SIMULATION_MIGRATION_DATABASE_URL }}" + } + + assert migration_wiring_violations(workflow["jobs"], workflow_env=workflow["env"]) def test_rule_proof_migrate_before_entrypoint(): diff --git a/rules/registry.toml b/rules/registry.toml index 509cf7cbb..ea16e6a58 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -230,6 +230,7 @@ nodes = [ "tests/test_ownership_boundaries.py::test_the_comparison_paths_database_url_may_reach_modal_until_it_is_removed", "tests/test_ownership_boundaries.py::test_a_modal_script_may_not_carry_another_database_url", "tests/test_ownership_boundaries.py::test_modal_sources_may_name_only_the_comparison_paths_database_url", + "tests/test_ownership_boundaries.py::test_a_temporary_modal_app_may_not_receive_another_database_url", ] [[rule.ci]] workflow = ".github/workflows/pr.yml" From 433f44e7b8a272c5404b1737ae5d718c92629945 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:11:41 +0400 Subject: [PATCH 038/124] Correct documentation drift and share one revision helper in the tests - The persistence README's test commands and the Alembic configuration's example now use --frozen, as every install and test run must. - DEPLOYMENT.md said the candidate smoke asks again "up to six times"; the default of six is six requests in all. - The deploy script's comment on the Stage 12 wrapper now says what is separate (the wrapper) and what both blocks still use (the secret lookups that predate the split). - The migration-cycle and migration-script tests read the applied revision through one helper in migration_support instead of two copies. Co-Authored-By: Claude Opus 5.5 --- .../deploy-cloud-run-simulation-entry.sh | 5 ++-- .../README.md | 4 ++-- .../tests/migration_support.py | 14 +++++++++++ .../tests/test_migration_cycle.py | 23 ++++++------------- .../tests/test_migration_script.py | 21 ++++++----------- migrations/alembic.ini | 2 +- .../DEPLOYMENT.md | 5 ++-- 7 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 183fc3329..0956ae9a5 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -70,8 +70,9 @@ add_runtime_secret() { runtime_secrets+=("${variable_name}=${resolved_reference}") } -# The Stage 12 comparison path's own copy, so it shares no deploy helper with -# the main path and is removed with it. +# The Stage 12 comparison path's own wrapper, removed with it, so the main +# path's wrapper can change without touching the Stage 12 block. Both use the +# secret lookups above, which predate the split. add_stage12_runtime_secret() { local variable_name="$1" local resolved_reference diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 79d80d5f7..ab82daf90 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -102,8 +102,8 @@ the same time rebases onto the new head after the first merges. ## Tests ```bash -uv sync --extra test -uv run pytest tests/ +uv sync --frozen --extra test +uv run --frozen pytest tests/ ``` Tests marked `postgres` need a disposable PostgreSQL server. Point diff --git a/libs/policyengine-simulation-persistence/tests/migration_support.py b/libs/policyengine-simulation-persistence/tests/migration_support.py index 9f1d02faa..8efb3d13e 100644 --- a/libs/policyengine-simulation-persistence/tests/migration_support.py +++ b/libs/policyengine-simulation-persistence/tests/migration_support.py @@ -7,6 +7,10 @@ from alembic import command from alembic.config import Config +from policyengine_simulation_persistence import ( + create_simulation_engine, + read_schema_revision, +) if TYPE_CHECKING: from collections.abc import Callable @@ -59,3 +63,13 @@ def migration_cycle_failures(config: Config, *, check: bool) -> list[str]: if failure is not None: return [failure] return [] + + +def applied_revision(database_url: str) -> str | None: + """Return the revision the database at ``database_url`` has applied.""" + engine = create_simulation_engine(database_url) + try: + with engine.connect() as connection: + return read_schema_revision(connection) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index 0772c5a24..d033ec4aa 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -10,6 +10,7 @@ MIGRATOR_ROLE, REPOSITORY_ROOT, alembic_config, + applied_revision, chain_config, migration_cycle_failures, ) @@ -17,7 +18,6 @@ HEAD_REVISION, SIMULATION_SCHEMA, create_simulation_engine, - read_schema_revision, ) from sqlalchemy import create_engine, inspect, text from sqlalchemy.exc import OperationalError, ProgrammingError @@ -25,15 +25,6 @@ pytestmark = pytest.mark.postgres -def _revision(database_url: str) -> str | None: - engine = create_simulation_engine(database_url) - try: - with engine.connect() as connection: - return read_schema_revision(connection) - finally: - engine.dispose() - - def _superuser_engine(database, **options): return create_engine( database.superuser_url.replace("postgresql://", "postgresql+psycopg://", 1), @@ -45,10 +36,10 @@ def test_upgrade_check_downgrade_upgrade_cycle(disposable_database) -> None: config = alembic_config() assert migration_cycle_failures(config, check=True) == [] - assert _revision(disposable_database.migrator_url) == HEAD_REVISION + assert applied_revision(disposable_database.migrator_url) == HEAD_REVISION command.downgrade(config, "base") - assert _revision(disposable_database.migrator_url) is None + assert applied_revision(disposable_database.migrator_url) is None def test_rule_proof_migration_cycle(disposable_database) -> None: @@ -93,7 +84,7 @@ def test_the_version_table_lives_only_in_the_simulation_schema( def test_an_unmigrated_database_reports_no_revision(disposable_database) -> None: - assert _revision(disposable_database.migrator_url) is None + assert applied_revision(disposable_database.migrator_url) is None def _precreate_schema_without_database_create(database) -> None: @@ -130,8 +121,8 @@ def test_a_precreated_schema_needs_no_database_create_privilege( command.upgrade(alembic_config(), "head") - assert _revision(disposable_database.migrator_url) == HEAD_REVISION - assert _revision(disposable_database.runtime_url) == HEAD_REVISION + assert applied_revision(disposable_database.migrator_url) == HEAD_REVISION + assert applied_revision(disposable_database.runtime_url) == HEAD_REVISION def test_the_runtime_role_reads_the_revision_but_cannot_change_the_schema( @@ -139,7 +130,7 @@ def test_the_runtime_role_reads_the_revision_but_cannot_change_the_schema( ) -> None: command.upgrade(alembic_config(), "head") - assert _revision(disposable_database.runtime_url) == HEAD_REVISION + assert applied_revision(disposable_database.runtime_url) == HEAD_REVISION engine = create_simulation_engine(disposable_database.runtime_url) try: with ( diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_script.py b/libs/policyengine-simulation-persistence/tests/test_migration_script.py index b4d0ebc2e..e7b09e217 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_script.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_script.py @@ -7,11 +7,13 @@ from pathlib import Path import pytest -from migration_support import MIGRATION_DATABASE_URL_VARIABLE, REPOSITORY_ROOT +from migration_support import ( + MIGRATION_DATABASE_URL_VARIABLE, + REPOSITORY_ROOT, + applied_revision, +) from policyengine_simulation_persistence import ( HEAD_REVISION, - create_simulation_engine, - read_schema_revision, ) pytestmark = pytest.mark.postgres @@ -35,26 +37,17 @@ def _run_script(database, mode: str) -> subprocess.CompletedProcess[str]: ) -def _revision(database) -> str | None: - engine = create_simulation_engine(database.migrator_url) - try: - with engine.connect() as connection: - return read_schema_revision(connection) - finally: - engine.dispose() - - def test_verify_mode_refuses_a_database_behind_the_chain(disposable_database) -> None: result = _run_script(disposable_database, "verify") assert result.returncode != 0 - assert _revision(disposable_database) is None + assert applied_revision(disposable_database.migrator_url) is None def test_upgrade_mode_migrates_and_then_verify_mode_passes(disposable_database) -> None: upgraded = _run_script(disposable_database, "upgrade") assert upgraded.returncode == 0, upgraded.stderr - assert _revision(disposable_database) == HEAD_REVISION + assert applied_revision(disposable_database.migrator_url) == HEAD_REVISION verified = _run_script(disposable_database, "verify") assert verified.returncode == 0, verified.stderr diff --git a/migrations/alembic.ini b/migrations/alembic.ini index fea6f84b2..66aaf1189 100644 --- a/migrations/alembic.ini +++ b/migrations/alembic.ini @@ -1,6 +1,6 @@ # The Simulation Entrypoint's only Alembic chain: the `simulation` schema. # Run it from the persistence library's environment, for example: -# uv run --project libs/policyengine-simulation-persistence --extra migrations \ +# uv run --frozen --project libs/policyengine-simulation-persistence --extra migrations \ # alembic -c migrations/alembic.ini upgrade head # The database URL comes only from SIMULATION_MIGRATION_DATABASE_URL. # Ownership rules: libs/policyengine-simulation-persistence/README.md diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 027cf74c8..87ddd1f5e 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -49,8 +49,9 @@ read it. Modal never receives either URL. image expects, under `simulation_database`. The database does not change the readiness status, because no old-gateway or Stage 12 route uses it. The candidate smoke check requires the two revisions to match, which shows that the -runtime secret reached the revision and the migration ran. It asks again, up to -six times, while a cold candidate has not reached its database yet; the stable and +runtime secret reached the revision and the migration ran. While a cold +candidate has not reached its database yet, it asks again, up to six requests +in all; the stable and rollback checks do not, because an older image may run against a newer schema. The ownership rules and roles are described in `libs/policyengine-simulation-persistence/README.md`. From c4009dccbdec2e5c16d7f3fdbb0dabe7142d9387 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:24:05 +0400 Subject: [PATCH 039/124] Add simulation.simulations: what to run, independent of model versions A simulation is one policy applied to one population: a household, or an economy named by a region code (state/ca) with an optional dataset name, where a null dataset name means the region's default dataset in whichever catalog version a run uses. A null policy means current law. The row holds no catalog version, so the same simulation can run again on a newer one; each run resolves the region and dataset in its own version (item 1.06). Revision 0002 creates the table with: - the population checked against the kind (a household simulation has a household and no region or dataset; an economy simulation has a region and no household); - foreign keys into the Public API's tables in the same database, all ON DELETE RESTRICT: (policy_id, country) to policies (id, country_id), (household_id, country) to households (id, country_id), and dynamic_id to dynamics, with an index on each referencing column; - a unique legacy_simulation_id for simulations migrated from the Public API's v1 database, and a unique (id, country, kind) for the reports' foreign keys; - no UPDATE privilege for the runtime role, since definitions never change. Creating the foreign keys needs the operator's REFERENCES grant on the Public API tables; a test shows the chain fails without it and leaves nothing applied. SimulationStore creates, reads and finds simulations by v1 ID, and create_migrated is idempotent by legacy_simulation_id for the one-time migration (item 2.05). Importing the package now registers every table, so the migration environment always compares the whole schema. Co-Authored-By: Claude Opus 5.5 --- .../__init__.py | 16 +- .../public_api.py | 45 +++ .../schema.py | 2 +- .../simulations.py | 300 ++++++++++++++++++ .../tests/conftest.py | 40 ++- .../tests/public_api_support.py | 126 ++++++++ .../tests/simulation_support.py | 43 +++ .../tests/test_definitions.py | 31 ++ .../tests/test_migration_cycle.py | 33 ++ .../tests/test_schema.py | 13 + .../tests/test_simulations.py | 212 +++++++++++++ .../versions/0002_simulations_and_reports.py | 121 +++++++ 12 files changed, 978 insertions(+), 4 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py create mode 100644 libs/policyengine-simulation-persistence/tests/public_api_support.py create mode 100644 libs/policyengine-simulation-persistence/tests/simulation_support.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_definitions.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_simulations.py create mode 100644 migrations/versions/0002_simulations_and_reports.py diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index fb098c8ed..000d62dae 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -1,4 +1,8 @@ -"""Tables and connections for the Simulation Entrypoint's ``simulation`` schema.""" +"""Tables and connections for the Simulation Entrypoint's ``simulation`` schema. + +Importing the package registers every table in ``simulation_metadata``, which +the migration environment compares with the database. +""" from policyengine_simulation_persistence.engine import ( create_simulation_engine, @@ -12,12 +16,22 @@ SIMULATION_SCHEMA, simulation_metadata, ) +from policyengine_simulation_persistence.simulations import ( + NewSimulation, + SimulationKind, + SimulationRecord, + SimulationStore, +) __all__ = [ "ALEMBIC_VERSION_TABLE", "HEAD_REVISION", "RUNTIME_ROLE", "SIMULATION_SCHEMA", + "NewSimulation", + "SimulationKind", + "SimulationRecord", + "SimulationStore", "create_simulation_engine", "normalized_database_url", "read_schema_revision", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py new file mode 100644 index 000000000..1da0d5b0d --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py @@ -0,0 +1,45 @@ +"""The Public API tables that rows in the simulation schema refer to. + +The Public API owns these tables and creates and changes them with its own +migration chain, in the same database as the ``simulation`` schema. They are +described here only so the simulation tables can declare foreign keys to them: +each lists just the key columns those foreign keys use, in a metadata object of +its own, so the simulation chain never compares, creates or changes them. + +The tables have no schema here because PostgreSQL reports a foreign key into +``public`` without a schema when ``public`` is on the search path; naming one +would make every schema comparison report the foreign keys as changed. The +migrations name ``public`` explicitly. + +Every foreign key into these tables is ``ON DELETE RESTRICT``: the Public API +cannot delete a row a simulation uses. Creating the foreign keys needs +``REFERENCES`` on each table for the migrator role; the runtime role needs no +privilege on them, because PostgreSQL runs a foreign key's check as the owner +of the referenced table. +""" + +from __future__ import annotations + +from sqlalchemy import Column, MetaData, String, Table, Uuid + +public_api_metadata = MetaData() + +policies = Table( + "policies", + public_api_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("country_id", String(2), nullable=False), +) +households = Table( + "households", + public_api_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("country_id", String(2), nullable=False), +) +dynamics = Table( + "dynamics", + public_api_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), +) + +REFERENCED_PUBLIC_API_TABLES = tuple(sorted(public_api_metadata.tables)) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py index a7cd53c18..7e4080638 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -17,7 +17,7 @@ # The newest revision in migrations/versions. Each migration PR updates it, and # tests/test_schema.py fails when it differs from the chain's head. The # readiness check reports it next to the database's revision. -HEAD_REVISION = "0001" +HEAD_REVISION = "0002" NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py new file mode 100644 index 000000000..31db413ad --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py @@ -0,0 +1,300 @@ +"""``simulation.simulations``: what to run, independent of model versions. + +A simulation is one policy applied to one population: a household, or an +economy named by a region code (``state/ca``) and an optional dataset name. It +is a definition, not an execution, and never changes once created. A null +policy means current law, and a null dataset name means the region's default +dataset in whichever catalog version a run uses, so the same simulation can +run again on a newer catalog version. + +Foreign keys into the Public API's tables (``public_api``) tie the policy and +the household to the simulation's country, and name an optional dynamic. The +region and dataset are not foreign keys here, because their rows belong to one +catalog version; each run resolves them in its own. + +A simulation migrated from the Public API's v1 database keeps its v1 +simulation ID in ``legacy_simulation_id``. That database is separate, so the v1 +ID is a plain unique column, not a foreign key. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, fields +from enum import StrEnum +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + DateTime, + ForeignKeyConstraint, + Index, + String, + Table, + Text, + UniqueConstraint, + Uuid, + func, + select, +) +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence import public_api +from policyengine_simulation_persistence.schema import simulation_metadata + +if TYPE_CHECKING: + from datetime import datetime + + from sqlalchemy import ColumnElement, Connection, Engine, RowMapping + +COUNTRY_PATTERN = re.compile(r"[a-z]{2}") +# Region codes and dataset names are the Public API's catalog keys: up to 255 +# characters, with no whitespace. +CATALOG_KEY_PATTERN = re.compile(r"\S{1,255}") +CATALOG_KEY_LENGTH = 255 + + +class SimulationKind(StrEnum): + """What a simulation runs on: one household or a whole economy.""" + + ECONOMY = "economy" + HOUSEHOLD = "household" + + +_KIND_VALUES = ", ".join(f"'{kind.value}'" for kind in SimulationKind) + +simulations = Table( + "simulations", + simulation_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("kind", Text, nullable=False), + Column("country", Text, nullable=False), + Column("policy_id", Uuid(as_uuid=True), nullable=True), + Column("dynamic_id", Uuid(as_uuid=True), nullable=True), + Column("household_id", Uuid(as_uuid=True), nullable=True), + Column("region_code", String(CATALOG_KEY_LENGTH), nullable=True), + Column("dataset_name", String(CATALOG_KEY_LENGTH), nullable=True), + Column("legacy_simulation_id", BigInteger, nullable=True, unique=True), + Column( + "created_at", + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ), + CheckConstraint(f"kind IN ({_KIND_VALUES})", name="kind"), + CheckConstraint("country ~ '^[a-z]{2}$'", name="country"), + CheckConstraint( + "(kind = 'household' AND household_id IS NOT NULL " + "AND region_code IS NULL AND dataset_name IS NULL) " + "OR (kind = 'economy' AND household_id IS NULL " + "AND region_code IS NOT NULL)", + name="population_matches_kind", + ), + CheckConstraint( + "region_code IS NULL OR region_code ~ '^\\S+$'", name="region_code" + ), + CheckConstraint( + "dataset_name IS NULL OR dataset_name ~ '^\\S+$'", name="dataset_name" + ), + CheckConstraint( + "legacy_simulation_id IS NULL OR legacy_simulation_id > 0", + name="legacy_simulation_id", + ), + # Reports refer to a simulation together with its country and kind, so a + # report can only compare simulations of its own country and kind. + UniqueConstraint("id", "country", "kind"), + ForeignKeyConstraint( + ["policy_id", "country"], + [public_api.policies.c.id, public_api.policies.c.country_id], + name="fk_simulations_policy_country", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["dynamic_id"], + [public_api.dynamics.c.id], + name="fk_simulations_dynamic", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["household_id", "country"], + [public_api.households.c.id, public_api.households.c.country_id], + name="fk_simulations_household_country", + ondelete="RESTRICT", + ), +) +# A Public API delete of a policy, household or dynamic checks these for +# references. +Index("ix_simulations_policy_id", simulations.c.policy_id) +Index("ix_simulations_household_id", simulations.c.household_id) +Index("ix_simulations_dynamic_id", simulations.c.dynamic_id) + + +def _check_catalog_key(field_name: str, value: str | None) -> None: + if value is not None and not CATALOG_KEY_PATTERN.fullmatch(value): + msg = f"{field_name} must be 1 to 255 characters with no whitespace" + raise ValueError(msg) + + +def check_legacy_id(field_name: str, value: int | None) -> None: + """Reject a v1 ID that is not a positive integer.""" + if value is not None and value <= 0: + msg = f"{field_name} must be a positive v1 ID" + raise ValueError(msg) + + +def check_country(country: str) -> None: + """Reject a country that is not a lowercase two-letter code.""" + if not COUNTRY_PATTERN.fullmatch(country): + msg = "country must be a lowercase two-letter code" + raise ValueError(msg) + + +def insert_or_get_existing( + connection: Connection, + table: Table, + values: dict[str, Any], + unique_column: Column[Any], +) -> RowMapping: + """Insert a row, or return the row that already has its unique value.""" + created = ( + connection.execute( + postgres_insert(table) + .values(**values) + .on_conflict_do_nothing(index_elements=[unique_column]) + .returning(*table.c) + ) + .mappings() + .first() + ) + if created is not None: + return created + return ( + connection.execute( + select(table).where(unique_column == values[unique_column.name]) + ) + .mappings() + .one() + ) + + +@dataclass(frozen=True) +class NewSimulation: + """A simulation to create. + + The database checks that the population matches the kind and that the + policy, household and dynamic exist for the country; the fields are + checked here first. + """ + + kind: SimulationKind + country: str + policy_id: UUID | None = None + dynamic_id: UUID | None = None + household_id: UUID | None = None + region_code: str | None = None + dataset_name: str | None = None + legacy_simulation_id: int | None = None + + def __post_init__(self) -> None: + """Reject malformed fields without touching the database.""" + check_country(self.country) + _check_catalog_key("region_code", self.region_code) + _check_catalog_key("dataset_name", self.dataset_name) + check_legacy_id("legacy_simulation_id", self.legacy_simulation_id) + + +@dataclass(frozen=True) +class SimulationRecord: + """One ``simulation.simulations`` row.""" + + id: UUID + kind: SimulationKind + country: str + policy_id: UUID | None + dynamic_id: UUID | None + household_id: UUID | None + region_code: str | None + dataset_name: str | None + legacy_simulation_id: int | None + created_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> SimulationRecord: + """Build the record from a selected ``simulations`` row.""" + values = {field.name: row[field.name] for field in fields(cls)} + values["kind"] = SimulationKind(values["kind"]) + return cls(**values) + + +def _new_simulation_values(new_simulation: NewSimulation) -> dict[str, Any]: + return { + "id": uuid4(), + **{ + field.name: getattr(new_simulation, field.name) + for field in fields(new_simulation) + }, + "kind": new_simulation.kind.value, + } + + +class SimulationStore: + """Typed SQLAlchemy Core reads and inserts for simulation rows.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + def create(self, new_simulation: NewSimulation) -> SimulationRecord: + """Insert one simulation and return it.""" + with self.engine.begin() as connection: + row = ( + connection.execute( + postgres_insert(simulations) + .values(**_new_simulation_values(new_simulation)) + .returning(*simulations.c) + ) + .mappings() + .one() + ) + return SimulationRecord.from_row(row) + + def create_migrated(self, new_simulation: NewSimulation) -> SimulationRecord: + """Create a simulation for a v1 simulation, or return the existing one. + + The v1 simulation ID must be set. An existing row is returned as it is, + even if ``new_simulation`` describes it differently, so running the + one-time migration again creates nothing new. + """ + if new_simulation.legacy_simulation_id is None: + msg = "a migrated simulation needs its legacy_simulation_id" + raise ValueError(msg) + with self.engine.begin() as connection: + row = insert_or_get_existing( + connection, + simulations, + _new_simulation_values(new_simulation), + simulations.c.legacy_simulation_id, + ) + return SimulationRecord.from_row(row) + + def get(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + return self._one(simulations.c.id == simulation_id) + + def get_by_legacy_simulation_id( + self, legacy_simulation_id: int + ) -> SimulationRecord | None: + """Return the simulation migrated from this v1 simulation, or None.""" + return self._one(simulations.c.legacy_simulation_id == legacy_simulation_id) + + def _one(self, condition: ColumnElement[bool]) -> SimulationRecord | None: + with self.engine.connect() as connection: + row = ( + connection.execute(select(simulations).where(condition)) + .mappings() + .first() + ) + return None if row is None else SimulationRecord.from_row(row) diff --git a/libs/policyengine-simulation-persistence/tests/conftest.py b/libs/policyengine-simulation-persistence/tests/conftest.py index 1726049dc..12a4821b6 100644 --- a/libs/policyengine-simulation-persistence/tests/conftest.py +++ b/libs/policyengine-simulation-persistence/tests/conftest.py @@ -15,12 +15,19 @@ from uuid import uuid4 import pytest +from alembic import command from migration_support import ( MIGRATION_DATABASE_URL_VARIABLE, MIGRATOR_ROLE, TEST_ROLE_PASSWORD, + alembic_config, ) from policyengine_simulation_persistence.schema import RUNTIME_ROLE +from public_api_support import ( + PublicApiRows, + create_public_api_tables, + insert_public_api_rows, +) from sqlalchemy import create_engine, text from sqlalchemy.engine import URL, make_url @@ -70,14 +77,27 @@ def postgres_server_url() -> URL: return make_url(configured).set(drivername="postgresql+psycopg") +@pytest.fixture +def references_granted() -> bool: + """Whether the migrator gets REFERENCES on the Public API tables. + + The operator grants it in production; a test overrides this fixture to + leave it out. + """ + return True + + @pytest.fixture def disposable_database( postgres_server_url: URL, monkeypatch: pytest.MonkeyPatch, + references_granted, ) -> Iterator[DisposableDatabase]: - """Create an empty database owned by the migrator, and drop it afterwards. + """Create a database owned by the migrator, and drop it afterwards. - ``SIMULATION_MIGRATION_DATABASE_URL`` points at it, as in the deploy job. + It holds only the Public API tables the simulation schema refers to + (``public_api_support``). ``SIMULATION_MIGRATION_DATABASE_URL`` points at + it, as in the deploy job. """ database_name = f"simulation_test_{uuid4().hex[:12]}" server_engine = create_engine(postgres_server_url, isolation_level="AUTOCOMMIT") @@ -106,6 +126,9 @@ def disposable_database( ), superuser_url=_canonical(postgres_server_url.set(database=database_name)), ) + create_public_api_tables( + database.superuser_url, grant_references=references_granted + ) monkeypatch.setenv(MIGRATION_DATABASE_URL_VARIABLE, database.migrator_url) yield database finally: @@ -114,3 +137,16 @@ def disposable_database( text(f'DROP DATABASE IF EXISTS "{database_name}" WITH (FORCE)') ) server_engine.dispose() + + +@pytest.fixture +def migrated_database(disposable_database: DisposableDatabase) -> DisposableDatabase: + """Return a disposable database with the whole chain applied.""" + command.upgrade(alembic_config(), "head") + return disposable_database + + +@pytest.fixture +def public_api_rows(migrated_database: DisposableDatabase) -> PublicApiRows: + """Insert one row of each kind the simulation tables refer to.""" + return insert_public_api_rows(migrated_database.superuser_url) diff --git a/libs/policyengine-simulation-persistence/tests/public_api_support.py b/libs/policyengine-simulation-persistence/tests/public_api_support.py new file mode 100644 index 000000000..cf144c68d --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/public_api_support.py @@ -0,0 +1,126 @@ +"""The Public API tables the simulation schema refers to, reduced to their keys. + +In production these tables belong to the Public API's own migration chain, in +the same database as the simulation schema. The tests create only the columns +and unique constraints the simulation schema's foreign keys use, with the +Public API's constraint names, owned by the superuser, and grant the migrator +``REFERENCES`` on them, as the operator does in production. The runtime role +gets no privilege on them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from migration_support import MIGRATOR_ROLE +from sqlalchemy import create_engine, text + +if TYPE_CHECKING: + from sqlalchemy import Engine + +PUBLIC_API_TABLE_STATEMENTS = ( + "CREATE TABLE public.policies (" + " id uuid PRIMARY KEY," + " country_id varchar(2) NOT NULL," + " CONSTRAINT uq_policies_id_country UNIQUE (id, country_id))", + "CREATE TABLE public.households (" + " id uuid PRIMARY KEY," + " country_id varchar(2) NOT NULL," + " CONSTRAINT uq_households_id_country UNIQUE (id, country_id))", + "CREATE TABLE public.dynamics (id uuid PRIMARY KEY)", +) +REFERENCED_TABLES = ("policies", "households", "dynamics") + + +def superuser_engine(superuser_url: str, **options: object) -> Engine: + """Return an engine that connects to the test database as the superuser.""" + return create_engine( + superuser_url.replace("postgresql://", "postgresql+psycopg://", 1), + **options, + ) + + +def create_public_api_tables(superuser_url: str, *, grant_references: bool) -> None: + """Create the referenced Public API tables in a new test database.""" + engine = superuser_engine(superuser_url, isolation_level="AUTOCOMMIT") + grant = ( + "GRANT REFERENCES ON " + + ", ".join(f"public.{table}" for table in REFERENCED_TABLES) + + f" TO {MIGRATOR_ROLE}" + ) + statements = ( + (*PUBLIC_API_TABLE_STATEMENTS, grant) + if grant_references + else PUBLIC_API_TABLE_STATEMENTS + ) + try: + with engine.connect() as connection: + for statement in statements: + connection.execute(text(statement)) + finally: + engine.dispose() + + +@dataclass(frozen=True) +class PublicApiRows: + """Public API rows a simulation can refer to.""" + + us_policy_id: UUID + uk_policy_id: UUID + us_household_id: UUID + uk_household_id: UUID + dynamic_id: UUID + + +def insert_public_api_rows(superuser_url: str) -> PublicApiRows: + """Insert rows into the referenced Public API tables, as their owner.""" + rows = PublicApiRows( + us_policy_id=uuid4(), + uk_policy_id=uuid4(), + us_household_id=uuid4(), + uk_household_id=uuid4(), + dynamic_id=uuid4(), + ) + statements = ( + ( + "INSERT INTO public.policies (id, country_id) VALUES " + "(:us_policy, 'us'), (:uk_policy, 'uk')", + {"us_policy": rows.us_policy_id, "uk_policy": rows.uk_policy_id}, + ), + ( + "INSERT INTO public.households (id, country_id) VALUES " + "(:us_household, 'us'), (:uk_household, 'uk')", + { + "us_household": rows.us_household_id, + "uk_household": rows.uk_household_id, + }, + ), + ( + "INSERT INTO public.dynamics (id) VALUES (:dynamic)", + {"dynamic": rows.dynamic_id}, + ), + ) + engine = superuser_engine(superuser_url) + try: + with engine.begin() as connection: + for statement, parameters in statements: + connection.execute(text(statement), parameters) + finally: + engine.dispose() + return rows + + +def delete_public_api_row(superuser_url: str, table_name: str, row_id: UUID) -> None: + """Delete one Public API row as its owner, as the Public API would.""" + assert table_name in REFERENCED_TABLES + engine = superuser_engine(superuser_url) + try: + with engine.begin() as connection: + connection.execute( + text(f"DELETE FROM public.{table_name} WHERE id = :row_id"), # noqa: S608 # table names come from REFERENCED_TABLES + {"row_id": row_id}, + ) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/simulation_support.py b/libs/policyengine-simulation-persistence/tests/simulation_support.py new file mode 100644 index 000000000..030ee2e93 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/simulation_support.py @@ -0,0 +1,43 @@ +"""Builders shared by the simulation and report tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence import ( + NewSimulation, + SimulationKind, + SimulationStore, + create_simulation_engine, +) + +if TYPE_CHECKING: + from public_api_support import PublicApiRows + + +def economy_simulation(rows: PublicApiRows, **overrides: object) -> NewSimulation: + """Return a California economy simulation of the US reform.""" + values: dict[str, object] = { + "kind": SimulationKind.ECONOMY, + "country": "us", + "policy_id": rows.us_policy_id, + "region_code": "state/ca", + } + values.update(overrides) + return NewSimulation(**values) # type: ignore[arg-type] + + +def household_simulation(rows: PublicApiRows, **overrides: object) -> NewSimulation: + """Return a current-law simulation of the US household.""" + values: dict[str, object] = { + "kind": SimulationKind.HOUSEHOLD, + "country": "us", + "household_id": rows.us_household_id, + } + values.update(overrides) + return NewSimulation(**values) # type: ignore[arg-type] + + +def simulation_store(database) -> SimulationStore: + """Return a store that connects as the runtime role.""" + return SimulationStore(create_simulation_engine(database.runtime_url)) diff --git a/libs/policyengine-simulation-persistence/tests/test_definitions.py b/libs/policyengine-simulation-persistence/tests/test_definitions.py new file mode 100644 index 000000000..f9b843a5c --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_definitions.py @@ -0,0 +1,31 @@ +"""New simulation and report definitions are checked before any write.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_persistence import NewSimulation, SimulationKind + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("country", "USA"), + ("country", "Us"), + ("region_code", ""), + ("region_code", " state/ca"), + ("region_code", "x" * 256), + ("dataset_name", ""), + ("dataset_name", "populace us"), + ("legacy_simulation_id", 0), + ], +) +def test_malformed_simulation_definitions_are_rejected(field_name, value) -> None: + values = { + "kind": SimulationKind.ECONOMY, + "country": "us", + "region_code": "state/ca", + field_name: value, + } + + with pytest.raises(ValueError, match=field_name): + NewSimulation(**values) diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index d033ec4aa..828c1a386 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -19,6 +19,7 @@ SIMULATION_SCHEMA, create_simulation_engine, ) +from simulation_support import economy_simulation, simulation_store from sqlalchemy import create_engine, inspect, text from sqlalchemy.exc import OperationalError, ProgrammingError @@ -196,3 +197,35 @@ def test_a_migration_waiting_for_a_lock_gives_up_instead_of_blocking( finally: holder.dispose() assert waited < 30 + + +@pytest.mark.parametrize("references_granted", [False]) +def test_the_chain_needs_references_on_the_public_api_tables( + disposable_database, +) -> None: + """Without the operator's REFERENCES grant, no foreign key can be created.""" + with pytest.raises(ProgrammingError, match="permission denied for table"): + command.upgrade(alembic_config(), "head") + + assert applied_revision(disposable_database.migrator_url) is None + + +@pytest.mark.parametrize("table_name", ["simulations"]) +def test_the_runtime_role_cannot_change_or_delete_a_definition( + migrated_database, public_api_rows, table_name +) -> None: + store = simulation_store(migrated_database) + store.create(economy_simulation(public_api_rows)) + engine = create_simulation_engine(migrated_database.runtime_url) + try: + for statement in ( + f"UPDATE simulation.{table_name} SET created_at = now()", # noqa: S608 # fixed table names + f"DELETE FROM simulation.{table_name}", # noqa: S608 # fixed table names + ): + with ( + pytest.raises(ProgrammingError, match="permission denied"), + engine.begin() as connection, + ): + connection.execute(text(statement)) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py index d42b8bf4d..2125d6c14 100644 --- a/libs/policyengine-simulation-persistence/tests/test_schema.py +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -2,6 +2,10 @@ from __future__ import annotations +import importlib +import pkgutil + +import policyengine_simulation_persistence import pytest from alembic import command from alembic.script import ScriptDirectory @@ -104,3 +108,12 @@ def test_migrations_refuse_offline_sql_generation(monkeypatch) -> None: with pytest.raises(RuntimeError, match="online"): command.upgrade(alembic_config(), "head", sql=True) + + +def test_importing_the_package_registers_every_table() -> None: + package_tables = set(simulation_metadata.tables) + for module in pkgutil.iter_modules(policyengine_simulation_persistence.__path__): + importlib.import_module(f"policyengine_simulation_persistence.{module.name}") + + assert set(simulation_metadata.tables) == package_tables + assert "simulation.simulations" in package_tables diff --git a/libs/policyengine-simulation-persistence/tests/test_simulations.py b/libs/policyengine-simulation-persistence/tests/test_simulations.py new file mode 100644 index 000000000..4b15d4d97 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_simulations.py @@ -0,0 +1,212 @@ +"""simulation.simulations: what to run, independent of model versions.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + NewSimulation, + SimulationKind, + SimulationRecord, +) +from public_api_support import delete_public_api_row +from simulation_support import ( + economy_simulation, + household_simulation, + simulation_store, +) +from sqlalchemy.exc import IntegrityError + +pytestmark = pytest.mark.postgres + + +def test_an_economy_simulation_names_a_region_by_code( + migrated_database, public_api_rows +) -> None: + store = simulation_store(migrated_database) + + created = store.create( + economy_simulation(public_api_rows, dataset_name="populace_us_2024") + ) + + assert isinstance(created, SimulationRecord) + assert created.kind is SimulationKind.ECONOMY + assert created.country == "us" + assert created.policy_id == public_api_rows.us_policy_id + assert created.region_code == "state/ca" + assert created.dataset_name == "populace_us_2024" + assert created.household_id is None + assert created.created_at.tzinfo is not None + assert store.get(created.id) == created + + +def test_a_null_dataset_name_means_the_region_default( + migrated_database, public_api_rows +) -> None: + created = simulation_store(migrated_database).create( + economy_simulation(public_api_rows) + ) + + assert created.dataset_name is None + + +def test_current_law_has_no_policy(migrated_database, public_api_rows) -> None: + created = simulation_store(migrated_database).create( + economy_simulation(public_api_rows, policy_id=None) + ) + + assert created.policy_id is None + + +def test_a_household_simulation_names_its_household_and_dynamic( + migrated_database, public_api_rows +) -> None: + created = simulation_store(migrated_database).create( + household_simulation( + public_api_rows, + policy_id=public_api_rows.us_policy_id, + dynamic_id=public_api_rows.dynamic_id, + ) + ) + + assert created.kind is SimulationKind.HOUSEHOLD + assert created.household_id == public_api_rows.us_household_id + assert created.dynamic_id == public_api_rows.dynamic_id + assert created.region_code is None + + +def _mismatched_population(rows, case: str) -> NewSimulation: + household = {"kind": SimulationKind.HOUSEHOLD, "household_id": rows.us_household_id} + economy = {"kind": SimulationKind.ECONOMY, "region_code": "state/ca"} + values = { + "household-without-household": {**household, "household_id": None}, + "household-with-region": {**household, "region_code": "state/ca"}, + "household-with-dataset": {**household, "dataset_name": "populace_us_2024"}, + "economy-without-region": {**economy, "region_code": None}, + "economy-with-household": {**economy, "household_id": rows.us_household_id}, + }[case] + return NewSimulation(country="us", **values) + + +@pytest.mark.parametrize( + "case", + [ + "household-without-household", + "household-with-region", + "household-with-dataset", + "economy-without-region", + "economy-with-household", + ], +) +def test_the_population_must_match_the_kind( + migrated_database, public_api_rows, case +) -> None: + with pytest.raises(IntegrityError, match="population_matches_kind"): + simulation_store(migrated_database).create( + _mismatched_population(public_api_rows, case) + ) + + +@pytest.mark.parametrize( + ("kind", "reference"), + [ + (SimulationKind.ECONOMY, "policy"), + (SimulationKind.HOUSEHOLD, "household"), + ], +) +def test_a_policy_or_household_must_be_of_the_simulation_country( + migrated_database, public_api_rows, kind, reference +) -> None: + if reference == "policy": + new_simulation = economy_simulation( + public_api_rows, country="uk", region_code="country/wales" + ) + constraint = "fk_simulations_policy_country" + else: + new_simulation = household_simulation(public_api_rows, country="uk") + constraint = "fk_simulations_household_country" + assert new_simulation.kind is kind + + with pytest.raises(IntegrityError, match=constraint): + simulation_store(migrated_database).create(new_simulation) + + +def test_an_unknown_dynamic_is_rejected(migrated_database, public_api_rows) -> None: + with pytest.raises(IntegrityError, match="fk_simulations_dynamic"): + simulation_store(migrated_database).create( + economy_simulation(public_api_rows, dynamic_id=uuid4()) + ) + + +def _simulation_using(rows, table_name: str) -> NewSimulation: + if table_name == "households": + return household_simulation(rows) + if table_name == "dynamics": + return economy_simulation(rows, dynamic_id=rows.dynamic_id) + return economy_simulation(rows) + + +@pytest.mark.parametrize( + ("table_name", "row_attribute"), + [ + ("policies", "us_policy_id"), + ("households", "us_household_id"), + ("dynamics", "dynamic_id"), + ], +) +def test_the_public_api_cannot_delete_a_row_a_simulation_uses( + migrated_database, public_api_rows, table_name, row_attribute +) -> None: + simulation_store(migrated_database).create( + _simulation_using(public_api_rows, table_name) + ) + + with pytest.raises(IntegrityError, match="violates foreign key constraint"): + delete_public_api_row( + migrated_database.superuser_url, + table_name, + getattr(public_api_rows, row_attribute), + ) + + +def test_a_migrated_simulation_is_created_once( + migrated_database, public_api_rows +) -> None: + store = simulation_store(migrated_database) + first = store.create_migrated( + economy_simulation(public_api_rows, legacy_simulation_id=4021) + ) + + again = store.create_migrated( + economy_simulation( + public_api_rows, legacy_simulation_id=4021, region_code="state/ny" + ) + ) + + assert again == first + assert store.get_by_legacy_simulation_id(4021) == first + assert store.get_by_legacy_simulation_id(4022) is None + + +def test_a_migrated_simulation_needs_its_legacy_id( + migrated_database, public_api_rows +) -> None: + with pytest.raises(ValueError, match="legacy_simulation_id"): + simulation_store(migrated_database).create_migrated( + economy_simulation(public_api_rows) + ) + + +def test_two_simulations_cannot_share_a_legacy_id( + migrated_database, public_api_rows +) -> None: + store = simulation_store(migrated_database) + store.create(economy_simulation(public_api_rows, legacy_simulation_id=7)) + + with pytest.raises(IntegrityError, match="legacy_simulation_id"): + store.create(economy_simulation(public_api_rows, legacy_simulation_id=7)) + + +def test_an_unknown_simulation_reads_as_none(migrated_database) -> None: + assert simulation_store(migrated_database).get(uuid4()) is None diff --git a/migrations/versions/0002_simulations_and_reports.py b/migrations/versions/0002_simulations_and_reports.py new file mode 100644 index 000000000..d5231da6e --- /dev/null +++ b/migrations/versions/0002_simulations_and_reports.py @@ -0,0 +1,121 @@ +"""Add the simulation definitions table. + +``simulations`` records what each simulation runs, independent of model +versions: its kind, country, policy (null for current law), dynamic, and either +a household or a region code with an optional dataset name. Foreign keys into +the Public API's policies, households and dynamics in the same database are +``ON DELETE RESTRICT`` and need ``REFERENCES`` on those tables for the migrator +role. Definitions never change, so the runtime role may read and insert them +but not update them. Nothing writes the table yet. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-10-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + +SCHEMA = "simulation" +PUBLIC_API_SCHEMA = "public" + + +def _public_api(table_and_column: str) -> str: + return f"{PUBLIC_API_SCHEMA}.{table_and_column}" + + +def _create_simulations() -> None: + op.create_table( + "simulations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("country", sa.Text(), nullable=False), + sa.Column("policy_id", sa.Uuid(), nullable=True), + sa.Column("dynamic_id", sa.Uuid(), nullable=True), + sa.Column("household_id", sa.Uuid(), nullable=True), + sa.Column("region_code", sa.String(length=255), nullable=True), + sa.Column("dataset_name", sa.String(length=255), nullable=True), + sa.Column("legacy_simulation_id", sa.BigInteger(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "kind IN ('economy', 'household')", + name="ck_simulations_kind", + ), + sa.CheckConstraint("country ~ '^[a-z]{2}$'", name="ck_simulations_country"), + sa.CheckConstraint( + "(kind = 'household' AND household_id IS NOT NULL " + "AND region_code IS NULL AND dataset_name IS NULL) " + "OR (kind = 'economy' AND household_id IS NULL " + "AND region_code IS NOT NULL)", + name="ck_simulations_population_matches_kind", + ), + sa.CheckConstraint( + "region_code IS NULL OR region_code ~ '^\\S+$'", + name="ck_simulations_region_code", + ), + sa.CheckConstraint( + "dataset_name IS NULL OR dataset_name ~ '^\\S+$'", + name="ck_simulations_dataset_name", + ), + sa.CheckConstraint( + "legacy_simulation_id IS NULL OR legacy_simulation_id > 0", + name="ck_simulations_legacy_simulation_id", + ), + sa.ForeignKeyConstraint( + ["policy_id", "country"], + [_public_api("policies.id"), _public_api("policies.country_id")], + name="fk_simulations_policy_country", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["dynamic_id"], + [_public_api("dynamics.id")], + name="fk_simulations_dynamic", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["household_id", "country"], + [_public_api("households.id"), _public_api("households.country_id")], + name="fk_simulations_household_country", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name="pk_simulations"), + sa.UniqueConstraint( + "legacy_simulation_id", name="uq_simulations_legacy_simulation_id" + ), + sa.UniqueConstraint( + "id", "country", "kind", name="uq_simulations_id_country_kind" + ), + schema=SCHEMA, + ) + for column in ("policy_id", "household_id", "dynamic_id"): + op.create_index( + f"ix_simulations_{column}", "simulations", [column], schema=SCHEMA + ) + + +def upgrade() -> None: + """Create the simulations table; the runtime role may not update it.""" + _create_simulations() + op.execute("REVOKE UPDATE ON simulation.simulations FROM simulation_runtime") + + +def downgrade() -> None: + """Drop the table ``upgrade`` created, with its indexes and privileges.""" + for column in ("dynamic_id", "household_id", "policy_id"): + op.drop_index( + f"ix_simulations_{column}", table_name="simulations", schema=SCHEMA + ) + op.drop_table("simulations", schema=SCHEMA) From 3a2dd8f6f1e77bf5930cfc5fa5b6a04112c850c3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:26:53 +0400 Subject: [PATCH 040/124] Add simulation.reports: which two simulations a report compares A report is a definition: its baseline simulation, its reform simulation (required for an economy report; a household report may have only a baseline), the year (1900 to 2200) and its options as a JSON object. Composite foreign keys (simulation id, country, kind) into simulation.simulations make both simulations share the report's country and kind; they are ON DELETE RESTRICT, so a simulation a report uses cannot be deleted. A report migrated from the Public API's v1 database keeps its v1 report ID in the unique legacy_report_id. Revision 0002 now creates the reports table too, and revokes UPDATE on both definition tables from the runtime role. ReportStore creates, reads and finds reports by v1 ID; create_migrated is idempotent by legacy_report_id, for the one-time migration (item 2.05). NewReport checks the country, year, options and v1 ID before any write; the database checks them again for any other writer. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 18 ++ .../__init__.py | 10 + .../reports.py | 226 ++++++++++++++++++ .../tests/report_support.py | 56 +++++ .../tests/test_definitions.py | 43 +++- .../tests/test_migration_cycle.py | 8 +- .../tests/test_reports.py | 190 +++++++++++++++ .../tests/test_schema.py | 2 +- .../versions/0002_simulations_and_reports.py | 83 ++++++- 9 files changed, 624 insertions(+), 12 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py create mode 100644 libs/policyengine-simulation-persistence/tests/report_support.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_reports.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index ab82daf90..aa689f9a2 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -8,6 +8,24 @@ The temporary Stage 12 comparison tables are not part of this schema: they are owned and migrated by the Public API, and `libs/policyengine-stage12-persistence` writes them with DML only. +## Tables + +| Table | Holds | +| --- | --- | +| `simulations` | What to run, independent of model versions: kind (`economy` or `household`), country, policy (null for current law), dynamic, and either a household or a region code (`state/ca`) with an optional dataset name (null means the region's default dataset in each catalog version) | +| `reports` | Which two simulations a report compares (an economy report needs a reform; a household report may have only a baseline), both of the report's own country and kind, with its year and options | + +Both are definitions: the runtime role may read and insert them but not update +or delete them. Rows migrated from the Public API's v1 database keep their v1 +IDs in the unique `legacy_simulation_id` and `legacy_report_id` columns, and +the stores' `create_migrated` methods return the existing row when the same v1 +ID is migrated again. + +Foreign keys into the Public API's tables in the same database (`policies` and +`households`, each together with its country, and `dynamics`) are +`ON DELETE RESTRICT`. Creating them needs `REFERENCES` on those tables for the +migrator role; the runtime role needs no privilege on them. + ## Ownership rules These rules are registered in `rules/registry.toml` and checked by diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 000d62dae..5e87ac336 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -9,6 +9,12 @@ normalized_database_url, read_schema_revision, ) +from policyengine_simulation_persistence.reports import ( + NewReport, + ReportKind, + ReportRecord, + ReportStore, +) from policyengine_simulation_persistence.schema import ( ALEMBIC_VERSION_TABLE, HEAD_REVISION, @@ -28,7 +34,11 @@ "HEAD_REVISION", "RUNTIME_ROLE", "SIMULATION_SCHEMA", + "NewReport", "NewSimulation", + "ReportKind", + "ReportRecord", + "ReportStore", "SimulationKind", "SimulationRecord", "SimulationStore", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py new file mode 100644 index 000000000..f68a4caa8 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py @@ -0,0 +1,226 @@ +"""``simulation.reports``: which two simulations a report compares. + +A report is an economy or household comparison a user can save: its baseline +simulation, its reform simulation (required for an economy report; a household +report may have only a baseline), the year and further options. It is a +definition and never changes. Both simulations must have the report's country +and kind, which composite foreign keys into ``simulation.simulations`` check. + +A report's calculations are its report runs (item 1.06); a report with none has +not been calculated yet. A report migrated from the Public API's v1 database +keeps its v1 report ID in ``legacy_report_id``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields +from dataclasses import field as dataclass_field +from enum import StrEnum +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + DateTime, + ForeignKeyConstraint, + Index, + Integer, + Table, + Text, + Uuid, + func, + select, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence.schema import simulation_metadata +from policyengine_simulation_persistence.simulations import ( + check_country, + check_legacy_id, + insert_or_get_existing, + simulations, +) + +if TYPE_CHECKING: + from datetime import datetime + + from sqlalchemy import ColumnElement, Engine, RowMapping + +FIRST_YEAR = 1900 +LAST_YEAR = 2200 + + +class ReportKind(StrEnum): + """What a report compares; its simulations have the same kind.""" + + ECONOMY = "economy" + HOUSEHOLD = "household" + + +_KIND_VALUES = ", ".join(f"'{kind.value}'" for kind in ReportKind) +_SIMULATION_KEY = [simulations.c.id, simulations.c.country, simulations.c.kind] + +reports = Table( + "reports", + simulation_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("kind", Text, nullable=False), + Column("country", Text, nullable=False), + Column("baseline_simulation_id", Uuid(as_uuid=True), nullable=False), + Column("reform_simulation_id", Uuid(as_uuid=True), nullable=True), + Column("year", Integer, nullable=False), + Column("options", JSONB, nullable=False, server_default=text("'{}'::jsonb")), + Column("legacy_report_id", BigInteger, nullable=True, unique=True), + Column( + "created_at", + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ), + CheckConstraint(f"kind IN ({_KIND_VALUES})", name="kind"), + CheckConstraint("country ~ '^[a-z]{2}$'", name="country"), + CheckConstraint(f"year BETWEEN {FIRST_YEAR} AND {LAST_YEAR}", name="year"), + CheckConstraint("jsonb_typeof(options) = 'object'", name="options"), + CheckConstraint( + "kind = 'household' OR reform_simulation_id IS NOT NULL", + name="economy_has_reform", + ), + CheckConstraint( + "legacy_report_id IS NULL OR legacy_report_id > 0", + name="legacy_report_id", + ), + ForeignKeyConstraint( + ["baseline_simulation_id", "country", "kind"], + _SIMULATION_KEY, + name="fk_reports_baseline_simulation", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["reform_simulation_id", "country", "kind"], + _SIMULATION_KEY, + name="fk_reports_reform_simulation", + ondelete="RESTRICT", + ), +) +Index("ix_reports_baseline_simulation_id", reports.c.baseline_simulation_id) +Index("ix_reports_reform_simulation_id", reports.c.reform_simulation_id) + + +@dataclass(frozen=True) +class NewReport: + """A report to create. + + The database checks that both simulations exist with the report's country + and kind, and that an economy report has a reform; the fields are checked + here first. + """ + + kind: ReportKind + country: str + baseline_simulation_id: UUID + year: int + reform_simulation_id: UUID | None = None + options: dict[str, Any] = dataclass_field(default_factory=dict) + legacy_report_id: int | None = None + + def __post_init__(self) -> None: + """Reject malformed fields without touching the database.""" + check_country(self.country) + if not FIRST_YEAR <= self.year <= LAST_YEAR: + msg = f"year must be between {FIRST_YEAR} and {LAST_YEAR}" + raise ValueError(msg) + if not isinstance(self.options, dict): + msg = "options must be a JSON object" + raise TypeError(msg) + check_legacy_id("legacy_report_id", self.legacy_report_id) + + +@dataclass(frozen=True) +class ReportRecord: + """One ``simulation.reports`` row.""" + + id: UUID + kind: ReportKind + country: str + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + year: int + options: dict[str, Any] + legacy_report_id: int | None + created_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> ReportRecord: + """Build the record from a selected ``reports`` row.""" + values = {field.name: row[field.name] for field in fields(cls)} + values["kind"] = ReportKind(values["kind"]) + return cls(**values) + + +def _new_report_values(new_report: NewReport) -> dict[str, Any]: + return { + "id": uuid4(), + **{field.name: getattr(new_report, field.name) for field in fields(new_report)}, + "kind": new_report.kind.value, + } + + +class ReportStore: + """Typed SQLAlchemy Core reads and inserts for report rows.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + def create(self, new_report: NewReport) -> ReportRecord: + """Insert one report and return it.""" + with self.engine.begin() as connection: + row = ( + connection.execute( + postgres_insert(reports) + .values(**_new_report_values(new_report)) + .returning(*reports.c) + ) + .mappings() + .one() + ) + return ReportRecord.from_row(row) + + def create_migrated(self, new_report: NewReport) -> ReportRecord: + """Create a report for a v1 report, or return the existing one. + + The v1 report ID must be set. An existing row is returned as it is, + even if ``new_report`` describes it differently, so running the + one-time migration again creates nothing new. The report has no run + until one is added. + """ + if new_report.legacy_report_id is None: + msg = "a migrated report needs its legacy_report_id" + raise ValueError(msg) + with self.engine.begin() as connection: + row = insert_or_get_existing( + connection, + reports, + _new_report_values(new_report), + reports.c.legacy_report_id, + ) + return ReportRecord.from_row(row) + + def get(self, report_id: UUID) -> ReportRecord | None: + """Return the report with this ID, or None.""" + return self._one(reports.c.id == report_id) + + def get_by_legacy_report_id(self, legacy_report_id: int) -> ReportRecord | None: + """Return the report migrated from this v1 report, or None.""" + return self._one(reports.c.legacy_report_id == legacy_report_id) + + def _one(self, condition: ColumnElement[bool]) -> ReportRecord | None: + with self.engine.connect() as connection: + row = ( + connection.execute(select(reports).where(condition)).mappings().first() + ) + return None if row is None else ReportRecord.from_row(row) diff --git a/libs/policyengine-simulation-persistence/tests/report_support.py b/libs/policyengine-simulation-persistence/tests/report_support.py new file mode 100644 index 000000000..2ce21677e --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/report_support.py @@ -0,0 +1,56 @@ +"""Builders for the report tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence import ( + NewReport, + ReportKind, + ReportStore, + create_simulation_engine, +) +from simulation_support import ( + economy_simulation, + household_simulation, + simulation_store, +) + +if TYPE_CHECKING: + from policyengine_simulation_persistence import SimulationRecord + from public_api_support import PublicApiRows + + +def report_store(database) -> ReportStore: + """Return a store that connects as the runtime role.""" + return ReportStore(create_simulation_engine(database.runtime_url)) + + +def economy_pair( + database, rows: PublicApiRows +) -> tuple[SimulationRecord, SimulationRecord]: + """Create a current-law baseline and a reform of California's economy.""" + store = simulation_store(database) + baseline = store.create(economy_simulation(rows, policy_id=None)) + reform = store.create(economy_simulation(rows)) + return baseline, reform + + +def economy_report( + baseline: SimulationRecord, reform: SimulationRecord, **overrides: object +) -> NewReport: + """Return a 2026 US economy report comparing the two simulations.""" + values: dict[str, object] = { + "kind": ReportKind.ECONOMY, + "country": "us", + "baseline_simulation_id": baseline.id, + "reform_simulation_id": reform.id, + "year": 2026, + } + values.update(overrides) + return NewReport(**values) # type: ignore[arg-type] + + +def household_baseline(database, rows: PublicApiRows) -> SimulationRecord: + """Create a current-law simulation of the US household.""" + return simulation_store(database).create(household_simulation(rows)) diff --git a/libs/policyengine-simulation-persistence/tests/test_definitions.py b/libs/policyengine-simulation-persistence/tests/test_definitions.py index f9b843a5c..614b53ea1 100644 --- a/libs/policyengine-simulation-persistence/tests/test_definitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_definitions.py @@ -2,8 +2,15 @@ from __future__ import annotations +from uuid import uuid4 + import pytest -from policyengine_simulation_persistence import NewSimulation, SimulationKind +from policyengine_simulation_persistence import ( + NewReport, + NewSimulation, + ReportKind, + SimulationKind, +) @pytest.mark.parametrize( @@ -29,3 +36,37 @@ def test_malformed_simulation_definitions_are_rejected(field_name, value) -> Non with pytest.raises(ValueError, match=field_name): NewSimulation(**values) + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("country", "gb "), + ("year", 1899), + ("year", 2201), + ("legacy_report_id", -4), + ], +) +def test_malformed_report_definitions_are_rejected(field_name, value) -> None: + values = { + "kind": ReportKind.ECONOMY, + "country": "us", + "baseline_simulation_id": uuid4(), + "reform_simulation_id": uuid4(), + "year": 2026, + field_name: value, + } + + with pytest.raises(ValueError, match=field_name): + NewReport(**values) + + +def test_report_options_must_be_a_json_object() -> None: + with pytest.raises(TypeError, match="options"): + NewReport( + kind=ReportKind.ECONOMY, + country="us", + baseline_simulation_id=uuid4(), + year=2026, + options=["not", "an", "object"], # type: ignore[arg-type] + ) diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index 828c1a386..fcf332e5e 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -19,7 +19,7 @@ SIMULATION_SCHEMA, create_simulation_engine, ) -from simulation_support import economy_simulation, simulation_store +from report_support import economy_pair, economy_report, report_store from sqlalchemy import create_engine, inspect, text from sqlalchemy.exc import OperationalError, ProgrammingError @@ -210,12 +210,12 @@ def test_the_chain_needs_references_on_the_public_api_tables( assert applied_revision(disposable_database.migrator_url) is None -@pytest.mark.parametrize("table_name", ["simulations"]) +@pytest.mark.parametrize("table_name", ["simulations", "reports"]) def test_the_runtime_role_cannot_change_or_delete_a_definition( migrated_database, public_api_rows, table_name ) -> None: - store = simulation_store(migrated_database) - store.create(economy_simulation(public_api_rows)) + baseline, reform = economy_pair(migrated_database, public_api_rows) + report_store(migrated_database).create(economy_report(baseline, reform)) engine = create_simulation_engine(migrated_database.runtime_url) try: for statement in ( diff --git a/libs/policyengine-simulation-persistence/tests/test_reports.py b/libs/policyengine-simulation-persistence/tests/test_reports.py new file mode 100644 index 000000000..9d40c597a --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_reports.py @@ -0,0 +1,190 @@ +"""simulation.reports: which simulations a report compares, its year and options.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ReportKind, ReportRecord +from public_api_support import superuser_engine +from report_support import ( + economy_pair, + economy_report, + household_baseline, + report_store, +) +from simulation_support import economy_simulation, simulation_store +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +pytestmark = pytest.mark.postgres + + +def test_an_economy_report_compares_a_baseline_and_a_reform( + migrated_database, public_api_rows +) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + store = report_store(migrated_database) + + created = store.create( + economy_report(baseline, reform, options={"target": "cliffs"}) + ) + + assert isinstance(created, ReportRecord) + assert created.kind is ReportKind.ECONOMY + assert created.baseline_simulation_id == baseline.id + assert created.reform_simulation_id == reform.id + assert created.year == 2026 + assert created.options == {"target": "cliffs"} + assert created.legacy_report_id is None + assert store.get(created.id) == created + + +def test_a_household_report_may_have_only_a_baseline( + migrated_database, public_api_rows +) -> None: + baseline = household_baseline(migrated_database, public_api_rows) + + created = report_store(migrated_database).create( + economy_report( + baseline, + baseline, + kind=ReportKind.HOUSEHOLD, + reform_simulation_id=None, + ) + ) + + assert created.kind is ReportKind.HOUSEHOLD + assert created.reform_simulation_id is None + assert created.options == {} + + +def test_an_economy_report_needs_a_reform(migrated_database, public_api_rows) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(IntegrityError, match="economy_has_reform"): + report_store(migrated_database).create( + economy_report(baseline, reform, reform_simulation_id=None) + ) + + +def test_simulations_must_have_the_report_country( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + uk_reform = simulation_store(migrated_database).create( + economy_simulation( + public_api_rows, + country="uk", + policy_id=public_api_rows.uk_policy_id, + region_code="country/wales", + ) + ) + + with pytest.raises(IntegrityError, match="fk_reports_reform_simulation"): + report_store(migrated_database).create(economy_report(baseline, uk_reform)) + + +def test_simulations_must_have_the_report_kind( + migrated_database, public_api_rows +) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(IntegrityError, match="fk_reports_baseline_simulation"): + report_store(migrated_database).create( + economy_report( + baseline, + reform, + kind=ReportKind.HOUSEHOLD, + reform_simulation_id=None, + ) + ) + + +def test_an_unknown_simulation_is_rejected(migrated_database, public_api_rows) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(IntegrityError, match="fk_reports_reform_simulation"): + report_store(migrated_database).create( + economy_report(baseline, reform, reform_simulation_id=uuid4()) + ) + + +@pytest.mark.parametrize( + ("column", "value", "constraint"), + [ + ("year", "1899", "ck_reports_year"), + ("options", "'[]'::jsonb", "ck_reports_options"), + ], +) +def test_the_database_checks_the_year_and_options( + migrated_database, public_api_rows, column, value, constraint +) -> None: + """The database checks what NewReport also checks, for any other writer.""" + baseline, reform = economy_pair(migrated_database, public_api_rows) + values = {"year": "2026", "options": "'{}'::jsonb", column: value} + engine = superuser_engine(migrated_database.superuser_url) + try: + with ( + pytest.raises(IntegrityError, match=constraint), + engine.begin() as connection, + ): + connection.execute( + text( + "INSERT INTO simulation.reports (id, kind, country, " # noqa: S608 # fixed test values + "baseline_simulation_id, reform_simulation_id, year, options) " + f"VALUES (:id, 'economy', 'us', :baseline, :reform, " + f"{values['year']}, {values['options']})" + ), + {"id": uuid4(), "baseline": baseline.id, "reform": reform.id}, + ) + finally: + engine.dispose() + + +def test_the_public_api_cannot_delete_a_simulation_a_report_uses( + migrated_database, public_api_rows +) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + report_store(migrated_database).create(economy_report(baseline, reform)) + engine = superuser_engine(migrated_database.superuser_url) + try: + with ( + pytest.raises(IntegrityError, match="violates foreign key constraint"), + engine.begin() as connection, + ): + connection.execute( + text("DELETE FROM simulation.simulations WHERE id = :id"), + {"id": baseline.id}, + ) + finally: + engine.dispose() + + +def test_a_migrated_report_is_created_once(migrated_database, public_api_rows) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + store = report_store(migrated_database) + first = store.create_migrated(economy_report(baseline, reform, legacy_report_id=91)) + + again = store.create_migrated( + economy_report(baseline, reform, legacy_report_id=91, year=2027) + ) + + assert again == first + assert store.get_by_legacy_report_id(91) == first + assert store.get_by_legacy_report_id(92) is None + + +def test_a_migrated_report_needs_its_legacy_id( + migrated_database, public_api_rows +) -> None: + baseline, reform = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(ValueError, match="legacy_report_id"): + report_store(migrated_database).create_migrated( + economy_report(baseline, reform) + ) + + +def test_an_unknown_report_reads_as_none(migrated_database) -> None: + assert report_store(migrated_database).get(uuid4()) is None diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py index 2125d6c14..8ac5b7b05 100644 --- a/libs/policyengine-simulation-persistence/tests/test_schema.py +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -116,4 +116,4 @@ def test_importing_the_package_registers_every_table() -> None: importlib.import_module(f"policyengine_simulation_persistence.{module.name}") assert set(simulation_metadata.tables) == package_tables - assert "simulation.simulations" in package_tables + assert {"simulation.simulations", "simulation.reports"} <= package_tables diff --git a/migrations/versions/0002_simulations_and_reports.py b/migrations/versions/0002_simulations_and_reports.py index d5231da6e..e6351e1ac 100644 --- a/migrations/versions/0002_simulations_and_reports.py +++ b/migrations/versions/0002_simulations_and_reports.py @@ -1,12 +1,14 @@ -"""Add the simulation definitions table. +"""Add the simulation and report definition tables. ``simulations`` records what each simulation runs, independent of model versions: its kind, country, policy (null for current law), dynamic, and either a household or a region code with an optional dataset name. Foreign keys into the Public API's policies, households and dynamics in the same database are ``ON DELETE RESTRICT`` and need ``REFERENCES`` on those tables for the migrator -role. Definitions never change, so the runtime role may read and insert them -but not update them. Nothing writes the table yet. +role. ``reports`` records which simulations each report compares, of the +report's own country and kind, with its year and options. Definitions never +change, so the runtime role may read and insert them but not update them. +Nothing writes the tables yet. Revision ID: 0002 Revises: 0001 @@ -17,6 +19,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects import postgresql revision = "0002" down_revision = "0001" @@ -106,14 +109,82 @@ def _create_simulations() -> None: ) +def _create_reports() -> None: + simulation_key = [ + f"{SCHEMA}.simulations.id", + f"{SCHEMA}.simulations.country", + f"{SCHEMA}.simulations.kind", + ] + op.create_table( + "reports", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("country", sa.Text(), nullable=False), + sa.Column("baseline_simulation_id", sa.Uuid(), nullable=False), + sa.Column("reform_simulation_id", sa.Uuid(), nullable=True), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column( + "options", + postgresql.JSONB(), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column("legacy_report_id", sa.BigInteger(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint("kind IN ('economy', 'household')", name="ck_reports_kind"), + sa.CheckConstraint("country ~ '^[a-z]{2}$'", name="ck_reports_country"), + sa.CheckConstraint("year BETWEEN 1900 AND 2200", name="ck_reports_year"), + sa.CheckConstraint( + "jsonb_typeof(options) = 'object'", name="ck_reports_options" + ), + sa.CheckConstraint( + "kind = 'household' OR reform_simulation_id IS NOT NULL", + name="ck_reports_economy_has_reform", + ), + sa.CheckConstraint( + "legacy_report_id IS NULL OR legacy_report_id > 0", + name="ck_reports_legacy_report_id", + ), + sa.ForeignKeyConstraint( + ["baseline_simulation_id", "country", "kind"], + simulation_key, + name="fk_reports_baseline_simulation", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["reform_simulation_id", "country", "kind"], + simulation_key, + name="fk_reports_reform_simulation", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name="pk_reports"), + sa.UniqueConstraint("legacy_report_id", name="uq_reports_legacy_report_id"), + schema=SCHEMA, + ) + for column in ("baseline_simulation_id", "reform_simulation_id"): + op.create_index(f"ix_reports_{column}", "reports", [column], schema=SCHEMA) + + def upgrade() -> None: - """Create the simulations table; the runtime role may not update it.""" + """Create both tables; the runtime role may not update either.""" _create_simulations() - op.execute("REVOKE UPDATE ON simulation.simulations FROM simulation_runtime") + _create_reports() + op.execute( + "REVOKE UPDATE ON simulation.simulations, simulation.reports " + "FROM simulation_runtime" + ) def downgrade() -> None: - """Drop the table ``upgrade`` created, with its indexes and privileges.""" + """Drop the tables ``upgrade`` created, with their indexes and privileges.""" + for column in ("reform_simulation_id", "baseline_simulation_id"): + op.drop_index(f"ix_reports_{column}", table_name="reports", schema=SCHEMA) + op.drop_table("reports", schema=SCHEMA) for column in ("dynamic_id", "household_id", "policy_id"): op.drop_index( f"ix_simulations_{column}", table_name="simulations", schema=SCHEMA From 3a15fb6ed0bb195d5e5b8695ac9b54c8e28ccfe2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:53:04 +0400 Subject: [PATCH 041/124] Accept a simulation or report kind given as text NewSimulation and NewReport typed kind as an enum but neither checked nor converted it, so a kind given as plain text (as a mapping of v1 records easily does) passed their checks and then failed with AttributeError when the store read kind.value. Both now convert the kind to its enum member when they are created, and reject an unknown kind with a ValueError that names the field. Co-Authored-By: Claude Opus 5.5 --- .../reports.py | 3 ++ .../simulations.py | 14 ++++++++++ .../tests/test_definitions.py | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py index f68a4caa8..99faccf40 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py @@ -42,6 +42,7 @@ check_country, check_legacy_id, insert_or_get_existing, + parse_kind, simulations, ) @@ -129,6 +130,8 @@ class NewReport: def __post_init__(self) -> None: """Reject malformed fields without touching the database.""" + # A frozen dataclass sets its own field through object.__setattr__. + object.__setattr__(self, "kind", parse_kind(self.kind, ReportKind)) check_country(self.country) if not FIRST_YEAR <= self.year <= LAST_YEAR: msg = f"year must be between {FIRST_YEAR} and {LAST_YEAR}" diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py index 31db413ad..0ad0e7629 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py @@ -145,6 +145,18 @@ def check_legacy_id(field_name: str, value: int | None) -> None: raise ValueError(msg) +def parse_kind[KindT: StrEnum](value: object, kind_type: type[KindT]) -> KindT: + """Return the ``kind_type`` member for ``value``, which may be its text. + + Raise ValueError naming ``kind`` when the value is not one of its members. + """ + try: + return kind_type(value) + except ValueError: + msg = f"kind must be one of {', '.join(kind_type)}" + raise ValueError(msg) from None + + def check_country(country: str) -> None: """Reject a country that is not a lowercase two-letter code.""" if not COUNTRY_PATTERN.fullmatch(country): @@ -200,6 +212,8 @@ class NewSimulation: def __post_init__(self) -> None: """Reject malformed fields without touching the database.""" + # A frozen dataclass sets its own field through object.__setattr__. + object.__setattr__(self, "kind", parse_kind(self.kind, SimulationKind)) check_country(self.country) _check_catalog_key("region_code", self.region_code) _check_catalog_key("dataset_name", self.dataset_name) diff --git a/libs/policyengine-simulation-persistence/tests/test_definitions.py b/libs/policyengine-simulation-persistence/tests/test_definitions.py index 614b53ea1..d15f35b35 100644 --- a/libs/policyengine-simulation-persistence/tests/test_definitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_definitions.py @@ -70,3 +70,31 @@ def test_report_options_must_be_a_json_object() -> None: year=2026, options=["not", "an", "object"], # type: ignore[arg-type] ) + + +def test_a_kind_given_as_text_becomes_the_enum() -> None: + simulation = NewSimulation(kind="economy", country="us", region_code="state/ca") # type: ignore[arg-type] + report = NewReport( + kind="household", # type: ignore[arg-type] + country="us", + baseline_simulation_id=uuid4(), + year=2026, + ) + + assert simulation.kind is SimulationKind.ECONOMY + assert report.kind is ReportKind.HOUSEHOLD + + +def test_an_unknown_simulation_kind_is_rejected() -> None: + with pytest.raises(ValueError, match="kind"): + NewSimulation(kind="microsimulation", country="us", region_code="state/ca") # type: ignore[arg-type] + + +def test_an_unknown_report_kind_is_rejected() -> None: + with pytest.raises(ValueError, match="kind"): + NewReport( + kind="microsimulation", # type: ignore[arg-type] + country="us", + baseline_simulation_id=uuid4(), + year=2026, + ) From e2dd3625dc5654cfc75555d2a533bceb994b8e1d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:54:33 +0400 Subject: [PATCH 042/124] Test every definition check in the database and correct the roles table The database repeats each check NewSimulation and NewReport make, for any other writer, but only the report year and options checks had a test, so a mistake in another check's expression would have gone unnoticed. New tests insert rows directly as the runtime role and expect each of the other checks to refuse them: the kinds, countries, region code, dataset name, both v1 IDs, and an economy report without a reform. The README's roles table still gave the runtime role UPDATE on every table, and did not say the migrator needs REFERENCES on the Public API tables before revision 0002 deploys; both are corrected. The unused REFERENCED_PUBLIC_API_TABLES constant is gone. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 14 ++++-- .../public_api.py | 2 - .../tests/test_reports.py | 43 ++++++++++++++++++- .../tests/test_simulations.py | 35 +++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index aa689f9a2..1436930e8 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -57,12 +57,18 @@ These rules are registered in `rules/registry.toml` and checked by | Role | Used by | Privileges | | --- | --- | --- | -| `simulation_migrator` | The deploy workflow's migration job, through `SIMULATION_MIGRATION_DATABASE_URL` | Creates the `simulation` schema if it is missing, and owns every object in it | -| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create | +| `simulation_migrator` | The deploy workflow's migration job, through `SIMULATION_MIGRATION_DATABASE_URL` | Creates the `simulation` schema if it is missing, and owns every object in it; `REFERENCES` on the Public API tables its foreign keys point to | +| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create, except `UPDATE` on the definition tables (`simulations`, `reports`); never `DELETE`, and nothing on the Public API tables | The initial revision grants the runtime role its privileges, including default -privileges on tables the migrator creates later. Both roles must exist before -the first migration runs; an operator creates them. The migrator needs either +privileges on tables the migrator creates later; revision 0002 revokes +`UPDATE` on the definition tables, which never change. Both roles must exist +before the first migration runs; an operator creates them. Before revision +0002 is deployed, the owner of the Public API's tables must also grant the +migrator `REFERENCES` on `policies`, `households` and `dynamics` (the +operator step grants it on `tax_benefit_model_versions`, `datasets` and +`regions` too, for later revisions); without it the migration fails and +applies nothing. The migrator needs either `CREATE` on the database or a `simulation` schema created in advance and owned by it: the environment creates the schema only when it is missing, because PostgreSQL checks `CREATE` on the database even for diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py index 1da0d5b0d..65f709a1f 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py @@ -41,5 +41,3 @@ public_api_metadata, Column("id", Uuid(as_uuid=True), primary_key=True), ) - -REFERENCED_PUBLIC_API_TABLES = tuple(sorted(public_api_metadata.tables)) diff --git a/libs/policyengine-simulation-persistence/tests/test_reports.py b/libs/policyengine-simulation-persistence/tests/test_reports.py index 9d40c597a..d1061a8b7 100644 --- a/libs/policyengine-simulation-persistence/tests/test_reports.py +++ b/libs/policyengine-simulation-persistence/tests/test_reports.py @@ -5,7 +5,12 @@ from uuid import uuid4 import pytest -from policyengine_simulation_persistence import ReportKind, ReportRecord +from policyengine_simulation_persistence import ( + ReportKind, + ReportRecord, + create_simulation_engine, +) +from policyengine_simulation_persistence.reports import reports from public_api_support import superuser_engine from report_support import ( economy_pair, @@ -14,7 +19,7 @@ report_store, ) from simulation_support import economy_simulation, simulation_store -from sqlalchemy import text +from sqlalchemy import insert, text from sqlalchemy.exc import IntegrityError pytestmark = pytest.mark.postgres @@ -188,3 +193,37 @@ def test_a_migrated_report_needs_its_legacy_id( def test_an_unknown_report_reads_as_none(migrated_database) -> None: assert report_store(migrated_database).get(uuid4()) is None + + +@pytest.mark.parametrize( + ("column", "value", "constraint"), + [ + ("kind", "microsimulation", "ck_reports_kind"), + ("country", "GB", "ck_reports_country"), + ("legacy_report_id", -4, "ck_reports_legacy_report_id"), + ("reform_simulation_id", None, "ck_reports_economy_has_reform"), + ], +) +def test_the_database_checks_each_report_field( + migrated_database, public_api_rows, column, value, constraint +) -> None: + """The database checks what NewReport also checks, for any other writer.""" + baseline, reform = economy_pair(migrated_database, public_api_rows) + values = { + "id": uuid4(), + "kind": "economy", + "country": "us", + "baseline_simulation_id": baseline.id, + "reform_simulation_id": reform.id, + "year": 2026, + column: value, + } + engine = create_simulation_engine(migrated_database.runtime_url) + try: + with ( + pytest.raises(IntegrityError, match=constraint), + engine.begin() as connection, + ): + connection.execute(insert(reports).values(**values)) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_simulations.py b/libs/policyengine-simulation-persistence/tests/test_simulations.py index 4b15d4d97..f574b8f26 100644 --- a/libs/policyengine-simulation-persistence/tests/test_simulations.py +++ b/libs/policyengine-simulation-persistence/tests/test_simulations.py @@ -9,13 +9,16 @@ NewSimulation, SimulationKind, SimulationRecord, + create_simulation_engine, ) +from policyengine_simulation_persistence.simulations import simulations from public_api_support import delete_public_api_row from simulation_support import ( economy_simulation, household_simulation, simulation_store, ) +from sqlalchemy import insert from sqlalchemy.exc import IntegrityError pytestmark = pytest.mark.postgres @@ -210,3 +213,35 @@ def test_two_simulations_cannot_share_a_legacy_id( def test_an_unknown_simulation_reads_as_none(migrated_database) -> None: assert simulation_store(migrated_database).get(uuid4()) is None + + +@pytest.mark.parametrize( + ("column", "value", "constraint"), + [ + ("kind", "microsimulation", "ck_simulations_kind"), + ("country", "USA", "ck_simulations_country"), + ("region_code", "state ca", "ck_simulations_region_code"), + ("dataset_name", "populace us", "ck_simulations_dataset_name"), + ("legacy_simulation_id", 0, "ck_simulations_legacy_simulation_id"), + ], +) +def test_the_database_checks_each_simulation_field( + migrated_database, public_api_rows, column, value, constraint +) -> None: + """The database checks what NewSimulation also checks, for any other writer.""" + values = { + "id": uuid4(), + "kind": "economy", + "country": "us", + "region_code": "state/ca", + column: value, + } + engine = create_simulation_engine(migrated_database.runtime_url) + try: + with ( + pytest.raises(IntegrityError, match=constraint), + engine.begin() as connection, + ): + connection.execute(insert(simulations).values(**values)) + finally: + engine.dispose() From c7404a186361be6d72b0b0380d897e30ad82f253 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:01:53 +0400 Subject: [PATCH 043/124] Name the check constraints as their table definitions do Revision 0002 passed full check constraint names, such as ck_simulations_kind, which the naming convention prefixed a second time, so the database held ck_simulations_ck_simulations_kind while the table definitions name it ck_simulations_kind. alembic check does not compare check constraints, so nothing noticed; a later revision dropping one by its defined name would have failed. The revision now marks the names as final (op.f), and a test compares every constraint name in the migrated database with the names the table definitions give. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_migration_cycle.py | 37 +++++++++++++++++++ .../versions/0002_simulations_and_reports.py | 28 ++++++++------ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py index fcf332e5e..b889e61a0 100644 --- a/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import time import pytest @@ -18,10 +19,13 @@ HEAD_REVISION, SIMULATION_SCHEMA, create_simulation_engine, + simulation_metadata, ) from report_support import economy_pair, economy_report, report_store from sqlalchemy import create_engine, inspect, text +from sqlalchemy.dialects import postgresql from sqlalchemy.exc import OperationalError, ProgrammingError +from sqlalchemy.schema import CreateTable pytestmark = pytest.mark.postgres @@ -229,3 +233,36 @@ def test_the_runtime_role_cannot_change_or_delete_a_definition( connection.execute(text(statement)) finally: engine.dispose() + + +def _declared_constraint_names() -> set[str]: + """Return the names the table definitions give their constraints.""" + names: set[str] = set() + for table in simulation_metadata.sorted_tables: + ddl = str(CreateTable(table).compile(dialect=postgresql.dialect())) + names.update(re.findall(r"CONSTRAINT (\w+)", ddl)) + return names + + +def test_every_constraint_has_the_name_its_definition_gives_it( + migrated_database, +) -> None: + """Compare names here, since alembic check does not compare check constraints.""" + engine = _superuser_engine(migrated_database) + try: + with engine.connect() as connection: + database_names = set( + connection.execute( + text( + "SELECT conname FROM pg_constraint " + "JOIN pg_namespace ON pg_namespace.oid = connamespace " + "WHERE nspname = 'simulation' " + "AND contype IN ('c', 'f', 'p', 'u') " + "AND conrelid <> 'simulation.alembic_version'::regclass" + ) + ).scalars() + ) + finally: + engine.dispose() + + assert database_names == _declared_constraint_names() diff --git a/migrations/versions/0002_simulations_and_reports.py b/migrations/versions/0002_simulations_and_reports.py index e6351e1ac..b3fd790de 100644 --- a/migrations/versions/0002_simulations_and_reports.py +++ b/migrations/versions/0002_simulations_and_reports.py @@ -54,27 +54,29 @@ def _create_simulations() -> None: ), sa.CheckConstraint( "kind IN ('economy', 'household')", - name="ck_simulations_kind", + name=op.f("ck_simulations_kind"), + ), + sa.CheckConstraint( + "country ~ '^[a-z]{2}$'", name=op.f("ck_simulations_country") ), - sa.CheckConstraint("country ~ '^[a-z]{2}$'", name="ck_simulations_country"), sa.CheckConstraint( "(kind = 'household' AND household_id IS NOT NULL " "AND region_code IS NULL AND dataset_name IS NULL) " "OR (kind = 'economy' AND household_id IS NULL " "AND region_code IS NOT NULL)", - name="ck_simulations_population_matches_kind", + name=op.f("ck_simulations_population_matches_kind"), ), sa.CheckConstraint( "region_code IS NULL OR region_code ~ '^\\S+$'", - name="ck_simulations_region_code", + name=op.f("ck_simulations_region_code"), ), sa.CheckConstraint( "dataset_name IS NULL OR dataset_name ~ '^\\S+$'", - name="ck_simulations_dataset_name", + name=op.f("ck_simulations_dataset_name"), ), sa.CheckConstraint( "legacy_simulation_id IS NULL OR legacy_simulation_id > 0", - name="ck_simulations_legacy_simulation_id", + name=op.f("ck_simulations_legacy_simulation_id"), ), sa.ForeignKeyConstraint( ["policy_id", "country"], @@ -136,19 +138,21 @@ def _create_reports() -> None: server_default=sa.text("now()"), nullable=False, ), - sa.CheckConstraint("kind IN ('economy', 'household')", name="ck_reports_kind"), - sa.CheckConstraint("country ~ '^[a-z]{2}$'", name="ck_reports_country"), - sa.CheckConstraint("year BETWEEN 1900 AND 2200", name="ck_reports_year"), sa.CheckConstraint( - "jsonb_typeof(options) = 'object'", name="ck_reports_options" + "kind IN ('economy', 'household')", name=op.f("ck_reports_kind") + ), + sa.CheckConstraint("country ~ '^[a-z]{2}$'", name=op.f("ck_reports_country")), + sa.CheckConstraint("year BETWEEN 1900 AND 2200", name=op.f("ck_reports_year")), + sa.CheckConstraint( + "jsonb_typeof(options) = 'object'", name=op.f("ck_reports_options") ), sa.CheckConstraint( "kind = 'household' OR reform_simulation_id IS NOT NULL", - name="ck_reports_economy_has_reform", + name=op.f("ck_reports_economy_has_reform"), ), sa.CheckConstraint( "legacy_report_id IS NULL OR legacy_report_id > 0", - name="ck_reports_legacy_report_id", + name=op.f("ck_reports_legacy_report_id"), ), sa.ForeignKeyConstraint( ["baseline_simulation_id", "country", "kind"], From f4bf51cc340f4fd266c2f8cdd26e2e811e066a2e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:20:42 +0400 Subject: [PATCH 044/124] Add simulation runs and report runs, found or created per catalog version and year Revision 0003 adds simulation.simulation_runs and simulation.report_runs. A simulation run is one execution of a simulation for a catalog version and a year. It records the region code and dataset row it resolved in that version (economy runs only), its status, Modal call ID, timings, error, trigger (initial, manual or system) and attempt number, unique per simulation, catalog version, year and attempt. Composite foreign keys keep it on its own version's rows: (catalog version, region code) through the Public API's uq_regions_model_version_code, and (dataset, catalog version) through uq_datasets_id_model_version, both ON DELETE RESTRICT. A key on (simulation, kind) lets the database check that only an economy run names a region and dataset. A report run joins one run of each of the report's simulations (no reform run for a single-simulation household report). Composite keys on (run, catalog version, year) make both runs share the report run's version and year. An economy report run carries its comparison step's own status and Modal call; a household report run has none, since one Modal call computes both households. Checks keep each status consistent with its Modal call, completion time, error and next check time, for the conditional status changes that follow. RunStore.start_simulation_run reuses the newest attempt while it is unfinished, or when it succeeded within the last 28 days, so its output files (kept 30 days) outlive the comparison; a failed run is never reused. Otherwise, and always when forced, it adds the next attempt, and concurrent starts settle on one row through the unique attempt number. create_report_run checks the runs belong to the report's simulations and takes the version and year from them; latest_report_run gives the report's current run. The runtime role may read, insert and update both tables but not delete them. Nothing writes them yet. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 32 +- .../__init__.py | 22 + .../public_api.py | 29 +- .../reports.py | 4 + .../run_store.py | 356 +++++++++++++ .../runs.py | 481 +++++++++++++++++ .../schema.py | 2 +- .../simulations.py | 3 + .../tests/public_api_support.py | 78 ++- .../tests/run_support.py | 85 +++ .../tests/test_runs.py | 495 ++++++++++++++++++ .../tests/test_schema.py | 7 +- .../0003_simulation_runs_and_report_runs.py | 328 ++++++++++++ 13 files changed, 1914 insertions(+), 8 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py create mode 100644 libs/policyengine-simulation-persistence/tests/run_support.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_runs.py create mode 100644 migrations/versions/0003_simulation_runs_and_report_runs.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 1436930e8..0a60b73e3 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -14,6 +14,8 @@ writes them with DML only. | --- | --- | | `simulations` | What to run, independent of model versions: kind (`economy` or `household`), country, policy (null for current law), dynamic, and either a household or a region code (`state/ca`) with an optional dataset name (null means the region's default dataset in each catalog version) | | `reports` | Which two simulations a report compares (an economy report needs a reform; a household report may have only a baseline), both of the report's own country and kind, with its year and options | +| `simulation_runs` | One execution of a simulation for a catalog version and year: the region code and dataset row it resolved in that version (economy runs only), its status, Modal call ID, timings, error, trigger (`initial`, `manual` or `system`) and attempt number | +| `report_runs` | One calculation of a report: a baseline and a reform simulation run of the same catalog version and year (no reform run for a single-simulation household report), the trigger, and, for an economy report, the comparison step's own status and Modal call | Both are definitions: the runtime role may read and insert them but not update or delete them. Rows migrated from the Public API's v1 database keep their v1 @@ -22,9 +24,33 @@ the stores' `create_migrated` methods return the existing row when the same v1 ID is migrated again. Foreign keys into the Public API's tables in the same database (`policies` and -`households`, each together with its country, and `dynamics`) are -`ON DELETE RESTRICT`. Creating them needs `REFERENCES` on those tables for the -migrator role; the runtime role needs no privilege on them. +`households`, each together with its country, `dynamics`, +`tax_benefit_model_versions`, and `regions` and `datasets` each together with +its catalog version) are `ON DELETE RESTRICT`. Creating them needs +`REFERENCES` on those tables for the migrator role; the runtime role needs no +privilege on them. + +### Runs + +A simulation run's setup is its catalog version (one policyengine.py bundle +version for one country, which pins the country package, data release and +datasets) and its year. Its region and dataset keys include its catalog +version, through the Public API's unique constraints +`uq_regions_model_version_code` and `uq_datasets_id_model_version`, so a run +can use only its own version's rows; the caller resolves them in that version +(the simulation's region code, and the dataset it names or the region's +default dataset). A report run's simulation run keys include its catalog +version and year, so both of its runs share them. + +`RunStore.start_simulation_run` reuses the newest attempt for the same +simulation, catalog version and year while it is unfinished, or when it +succeeded within the last 28 days (output files are kept 30); a failed run is +never reused. Otherwise, and always when forced, it adds the next attempt. +Concurrent starts settle on one row through the unique attempt number. +`RunStore.create_report_run` joins one run of each of the report's simulations +and takes the catalog version and year from them; a report's status is its +newest report run's. The runtime role may read, insert and update runs but +not delete them. ## Ownership rules diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 5e87ac336..a83a0d3e5 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -15,6 +15,19 @@ ReportRecord, ReportStore, ) +from policyengine_simulation_persistence.run_store import ( + SUCCEEDED_RUN_REUSE_WINDOW, + RunStore, +) +from policyengine_simulation_persistence.runs import ( + NewReportRun, + NewSimulationRun, + ReportRunRecord, + RunStatus, + RunTrigger, + SimulationRunRecord, + StartedSimulationRun, +) from policyengine_simulation_persistence.schema import ( ALEMBIC_VERSION_TABLE, HEAD_REVISION, @@ -34,14 +47,23 @@ "HEAD_REVISION", "RUNTIME_ROLE", "SIMULATION_SCHEMA", + "SUCCEEDED_RUN_REUSE_WINDOW", "NewReport", + "NewReportRun", "NewSimulation", + "NewSimulationRun", "ReportKind", "ReportRecord", + "ReportRunRecord", "ReportStore", + "RunStatus", + "RunStore", + "RunTrigger", "SimulationKind", "SimulationRecord", + "SimulationRunRecord", "SimulationStore", + "StartedSimulationRun", "create_simulation_engine", "normalized_database_url", "read_schema_revision", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py index 65f709a1f..5dfd974fd 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py @@ -20,7 +20,7 @@ from __future__ import annotations -from sqlalchemy import Column, MetaData, String, Table, Uuid +from sqlalchemy import Column, MetaData, String, Table, UniqueConstraint, Uuid public_api_metadata = MetaData() @@ -41,3 +41,30 @@ public_api_metadata, Column("id", Uuid(as_uuid=True), primary_key=True), ) +# A catalog version: one published set of model metadata for one country and +# one policyengine.py version. Each publication creates new region and dataset +# rows, unique by code or name within their version. +tax_benefit_model_versions = Table( + "tax_benefit_model_versions", + public_api_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), +) +regions = Table( + "regions", + public_api_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True), nullable=False), + Column("code", String(255), nullable=False), + UniqueConstraint( + "tax_benefit_model_version_id", "code", name="uq_regions_model_version_code" + ), +) +datasets = Table( + "datasets", + public_api_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True), nullable=False), + UniqueConstraint( + "id", "tax_benefit_model_version_id", name="uq_datasets_id_model_version" + ), +) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py index 99faccf40..ec911e10f 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py @@ -29,6 +29,7 @@ Integer, Table, Text, + UniqueConstraint, Uuid, func, select, @@ -106,6 +107,9 @@ class ReportKind(StrEnum): name="fk_reports_reform_simulation", ondelete="RESTRICT", ), + # Report runs refer to a report together with its kind, so only an economy + # report run has a comparison step. + UniqueConstraint("id", "kind"), ) Index("ix_reports_baseline_simulation_id", reports.c.baseline_simulation_id) Index("ix_reports_reform_simulation_id", reports.c.reform_simulation_id) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py new file mode 100644 index 000000000..e04547fc4 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py @@ -0,0 +1,356 @@ +"""Find, create and read simulation runs and report runs. + +Starting a simulation run reuses the newest attempt for the same simulation, +catalog version and year while it is unfinished, or succeeded within the +reuse window; otherwise, and always when forced, it adds the next attempt. +Concurrent starts settle on one row through the unique attempt number: the +loser of the insert returns the winner's run. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence.reports import ReportKind, reports +from policyengine_simulation_persistence.runs import ( + UNFINISHED_STATUSES, + NewReportRun, + NewSimulationRun, + ReportRunRecord, + RunStatus, + SimulationRunRecord, + StartedSimulationRun, + report_runs, + simulation_runs, +) +from policyengine_simulation_persistence.simulations import ( + SimulationKind, + simulations, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlalchemy import ColumnElement, Connection, Engine, RowMapping + +# Output files are kept 30 days. A succeeded run is reused only if it finished +# within the last 28, so its files outlive the comparison step that reads them. +SUCCEEDED_RUN_REUSE_WINDOW = timedelta(days=28) + + +def utc_now() -> datetime: + """Return the current time in UTC.""" + return datetime.now(UTC) + + +def _check_region_code(simulation: RowMapping, new_run: NewSimulationRun) -> None: + """Reject an economy run whose region code is not its simulation's. + + A missing region or dataset, or one on a household run, is left to the + database's population check. + """ + if ( + simulation["kind"] == SimulationKind.ECONOMY.value + and new_run.region_code is not None + and new_run.region_code != simulation["region_code"] + ): + msg = "an economy run's region_code must be its simulation's region code" + raise ValueError(msg) + + +def _check_report_run_simulations( + report: RowMapping, + baseline: RowMapping | None, + reform: RowMapping | None, + new_report_run: NewReportRun, +) -> RowMapping: + """Require one run of each of the report's simulations, and no other. + + Return the baseline run's key, from which the report run takes its catalog + version and year. + """ + expected_reform_run = report["reform_simulation_id"] is not None + if ( + baseline is None + or baseline["simulation_id"] != report["baseline_simulation_id"] + or expected_reform_run != (new_report_run.reform_simulation_run_id is not None) + or ( + expected_reform_run + and ( + reform is None + or reform["simulation_id"] != report["reform_simulation_id"] + ) + ) + ): + msg = ( + "a report run needs one existing run of each of the report's " + "simulations: its baseline and, when it has one, its reform" + ) + raise ValueError(msg) + return baseline + + +class RunStore: + """Typed SQLAlchemy Core reads and inserts for runs.""" + + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime] = utc_now, + ) -> None: + """Use ``engine`` (the runtime role) and ``clock`` for every timestamp.""" + self.engine = engine + self._clock = clock + + # --- Simulation runs --------------------------------------------------- + + def start_simulation_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Return the reusable run for this setup, or create the next attempt. + + A run is reused while it is unfinished, or when it succeeded within + ``SUCCEEDED_RUN_REUSE_WINDOW``; a failed run never is. ``force`` always + adds the next attempt, unless another caller added it at the same + time, in which case that run is returned. Raise LookupError when the + simulation does not exist. + """ + now = self._clock() + with self.engine.begin() as connection: + simulation = ( + connection.execute( + select(simulations.c.kind, simulations.c.region_code).where( + simulations.c.id == new_run.simulation_id + ) + ) + .mappings() + .first() + ) + if simulation is None: + msg = f"no simulation {new_run.simulation_id}" + raise LookupError(msg) + _check_region_code(simulation, new_run) + latest = self._latest_simulation_run( + connection, + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + if latest is not None and not force and self._reusable(latest, now): + return StartedSimulationRun(run=latest, created=False) + attempt = 1 if latest is None else latest.attempt + 1 + created = ( + connection.execute( + postgres_insert(simulation_runs) + .values( + id=uuid4(), + simulation_id=new_run.simulation_id, + simulation_kind=simulation["kind"], + tax_benefit_model_version_id=( + new_run.tax_benefit_model_version_id + ), + year=new_run.year, + attempt=attempt, + region_code=new_run.region_code, + dataset_id=new_run.dataset_id, + trigger=new_run.trigger.value, + status=RunStatus.PENDING.value, + created_at=now, + updated_at=now, + ) + .on_conflict_do_nothing( + index_elements=[ + simulation_runs.c.simulation_id, + simulation_runs.c.tax_benefit_model_version_id, + simulation_runs.c.year, + simulation_runs.c.attempt, + ] + ) + .returning(*simulation_runs.c) + ) + .mappings() + .first() + ) + if created is not None: + return StartedSimulationRun( + run=SimulationRunRecord.from_row(created), created=True + ) + # Another caller added this attempt after the read above; the + # insert waited for it to commit, so it is visible now. + concurrent = self._latest_simulation_run( + connection, + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + if concurrent is None: # pragma: no cover - the conflict proves a row + msg = "the conflicting simulation run disappeared" + raise RuntimeError(msg) + return StartedSimulationRun(run=concurrent, created=False) + + @staticmethod + def _reusable(run: SimulationRunRecord, now: datetime) -> bool: + if run.status in UNFINISHED_STATUSES: + return True + return ( + run.status is RunStatus.SUCCEEDED + and run.completed_at is not None + and run.completed_at >= now - SUCCEEDED_RUN_REUSE_WINDOW + ) + + @staticmethod + def _latest_simulation_run( + connection: Connection, + simulation_id: UUID, + catalog_version_id: UUID, + year: int, + ) -> SimulationRunRecord | None: + row = ( + connection.execute( + select(simulation_runs) + .where( + simulation_runs.c.simulation_id == simulation_id, + simulation_runs.c.tax_benefit_model_version_id + == catalog_version_id, + simulation_runs.c.year == year, + ) + .order_by(simulation_runs.c.attempt.desc()) + .limit(1) + ) + .mappings() + .first() + ) + return None if row is None else SimulationRunRecord.from_row(row) + + def get_simulation_run(self, run_id: UUID) -> SimulationRunRecord | None: + """Return the simulation run with this ID, or None.""" + with self.engine.connect() as connection: + row = ( + connection.execute( + select(simulation_runs).where(simulation_runs.c.id == run_id) + ) + .mappings() + .first() + ) + return None if row is None else SimulationRunRecord.from_row(row) + + def latest_simulation_run( + self, simulation_id: UUID, catalog_version_id: UUID, year: int + ) -> SimulationRunRecord | None: + """Return the newest attempt for this simulation, catalog version and year.""" + with self.engine.connect() as connection: + return self._latest_simulation_run( + connection, simulation_id, catalog_version_id, year + ) + + # --- Report runs ------------------------------------------------------- + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Add a report run joining two existing simulation runs. + + The report run takes its catalog version and year from its baseline + run; the database refuses a reform run of another version or year. + An economy report run starts with a pending comparison step. Raise + LookupError when the report does not exist, and ValueError when the + runs are not runs of the report's simulations. + """ + now = self._clock() + with self.engine.begin() as connection: + report = ( + connection.execute( + select( + reports.c.kind, + reports.c.baseline_simulation_id, + reports.c.reform_simulation_id, + ).where(reports.c.id == new_report_run.report_id) + ) + .mappings() + .first() + ) + if report is None: + msg = f"no report {new_report_run.report_id}" + raise LookupError(msg) + baseline = self._run_key( + connection, new_report_run.baseline_simulation_run_id + ) + reform = ( + None + if new_report_run.reform_simulation_run_id is None + else self._run_key(connection, new_report_run.reform_simulation_run_id) + ) + baseline = _check_report_run_simulations( + report, baseline, reform, new_report_run + ) + economy = report["kind"] == ReportKind.ECONOMY.value + row = ( + connection.execute( + postgres_insert(report_runs) + .values( + id=uuid4(), + report_id=new_report_run.report_id, + report_kind=report["kind"], + tax_benefit_model_version_id=( + baseline["tax_benefit_model_version_id"] + ), + year=baseline["year"], + baseline_simulation_run_id=( + new_report_run.baseline_simulation_run_id + ), + reform_simulation_run_id=( + new_report_run.reform_simulation_run_id + ), + trigger=new_report_run.trigger.value, + comparison_status=( + RunStatus.PENDING.value if economy else None + ), + created_at=now, + updated_at=now, + ) + .returning(*report_runs.c) + ) + .mappings() + .one() + ) + return ReportRunRecord.from_row(row) + + @staticmethod + def _run_key(connection: Connection, run_id: UUID) -> RowMapping | None: + return ( + connection.execute( + select( + simulation_runs.c.simulation_id, + simulation_runs.c.tax_benefit_model_version_id, + simulation_runs.c.year, + ).where(simulation_runs.c.id == run_id) + ) + .mappings() + .first() + ) + + def get_report_run(self, report_run_id: UUID) -> ReportRunRecord | None: + """Return the report run with this ID, or None.""" + return self._one_report_run(report_runs.c.id == report_run_id) + + def latest_report_run(self, report_id: UUID) -> ReportRunRecord | None: + """Return the report's newest report run, whose status is the report's.""" + return self._one_report_run(report_runs.c.report_id == report_id) + + def _one_report_run(self, condition: ColumnElement[bool]) -> ReportRunRecord | None: + with self.engine.connect() as connection: + row = ( + connection.execute( + select(report_runs) + .where(condition) + .order_by(report_runs.c.created_at.desc(), report_runs.c.id.desc()) + .limit(1) + ) + .mappings() + .first() + ) + return None if row is None else ReportRunRecord.from_row(row) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py new file mode 100644 index 000000000..d69b42012 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -0,0 +1,481 @@ +"""``simulation_runs`` and ``report_runs``: executions of simulations and reports. + +A simulation run is one execution of a simulation for a catalog version and a +year. It records the region and dataset rows it resolved in that catalog +version, its own status and Modal call, its timings and error, what started it +(its trigger) and its attempt number. Runs are unique per simulation, catalog +version, year and attempt, so a run that already exists is reused, and a forced +re-run adds the next attempt. + +A report run is one calculation of a report: a baseline and a reform +simulation run of the same catalog version and year (the reform is null for a +single-simulation household report), plus, for an economy report, the +comparison step's own status and Modal call. A household report has no +comparison step: one Modal call computes both households, so its simulation +runs share that call. + +Each status moves ``pending`` -> ``dispatching`` -> ``running`` -> ``succeeded`` +or ``failed``. The region and dataset keys include the catalog version, through +the Public API's unique constraints ``uq_regions_model_version_code`` and +``uq_datasets_id_model_version``, so a run can use only its own version's +rows. A report run's simulation run keys include its catalog version and year, +so both runs share them. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Self + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + ForeignKeyConstraint, + Index, + Integer, + String, + Table, + Text, + UniqueConstraint, + Uuid, + text, +) + +from policyengine_simulation_persistence import public_api +from policyengine_simulation_persistence.reports import ( + FIRST_YEAR, + LAST_YEAR, + ReportKind, + reports, +) +from policyengine_simulation_persistence.schema import simulation_metadata +from policyengine_simulation_persistence.simulations import ( + CATALOG_KEY_LENGTH, + SimulationKind, + simulations, +) + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + from sqlalchemy import RowMapping + +MAX_ERROR_MESSAGE_LENGTH = 2000 + + +class RunStatus(StrEnum): + """Where a simulation run, or a report run's comparison step, stands.""" + + PENDING = "pending" + DISPATCHING = "dispatching" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +UNFINISHED_STATUSES = (RunStatus.PENDING, RunStatus.DISPATCHING, RunStatus.RUNNING) +FINISHED_STATUSES = (RunStatus.SUCCEEDED, RunStatus.FAILED) + + +class RunTrigger(StrEnum): + """What started a run. + + ``initial`` is a report's or calculation's first submission, ``manual`` a + user's re-run, and ``system`` a run sim or a migration started itself, for + example the first opening of a migrated report. + """ + + INITIAL = "initial" + MANUAL = "manual" + SYSTEM = "system" + + +def _values(enum: type[StrEnum]) -> str: + return ", ".join(f"'{member.value}'" for member in enum) + + +def _status_checks(prefix: str, *, optional: bool) -> tuple[CheckConstraint, ...]: + """Return the checks that keep one status and its columns consistent. + + ``prefix`` is empty for a simulation run and ``comparison_`` for a report + run's comparison step, whose status is null (``optional``) when the report + run has no comparison step. + """ + status = f"{prefix}status" + unless_absent = f"{status} IS NULL OR " if optional else "" + return ( + CheckConstraint( + f"{unless_absent}{status} IN ({_values(RunStatus)})", + name=f"{prefix}status", + ), + # A Modal call exists once dispatching succeeded, never before; a + # failed run may have one or not. + CheckConstraint( + f"{unless_absent}" + f"({status} IN ('pending', 'dispatching') " + f"AND {prefix}modal_call_id IS NULL) " + f"OR ({status} IN ('running', 'succeeded') " + f"AND {prefix}modal_call_id IS NOT NULL) " + f"OR {status} = 'failed'", + name=f"{prefix}modal_call", + ), + CheckConstraint( + f"{unless_absent}" + f"({status} IN ('succeeded', 'failed')) " + f"= ({prefix}completed_at IS NOT NULL)", + name=f"{prefix}completed_at", + ), + CheckConstraint( + f"{unless_absent}({status} = 'failed') = ({prefix}error_code IS NOT NULL)", + name=f"{prefix}error", + ), + # Only a running call is checked, and every running call is due a check. + CheckConstraint( + f"{unless_absent}" + f"({status} = 'running') = ({prefix}next_check_at IS NOT NULL)", + name=f"{prefix}next_check_at", + ), + CheckConstraint(f"{prefix}check_count >= 0", name=f"{prefix}check_count"), + ) + + +def _execution_columns(prefix: str, *, required: bool) -> list[Column[Any]]: + """Return the status, Modal call, error and timing columns of one execution.""" + return [ + Column(f"{prefix}status", Text, nullable=not required), + Column(f"{prefix}modal_call_id", Text, nullable=True), + Column(f"{prefix}error_code", Text, nullable=True), + Column(f"{prefix}error_message", Text, nullable=True), + Column( + f"{prefix}check_count", + Integer, + nullable=False, + server_default=text("0"), + ), + Column(f"{prefix}dispatched_at", DateTime(timezone=True), nullable=True), + Column(f"{prefix}next_check_at", DateTime(timezone=True), nullable=True), + Column(f"{prefix}completed_at", DateTime(timezone=True), nullable=True), + ] + + +simulation_runs = Table( + "simulation_runs", + simulation_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("simulation_id", Uuid(as_uuid=True), nullable=False), + Column("simulation_kind", Text, nullable=False), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True), nullable=False), + Column("year", Integer, nullable=False), + Column("attempt", Integer, nullable=False), + Column("region_code", String(CATALOG_KEY_LENGTH), nullable=True), + Column("dataset_id", Uuid(as_uuid=True), nullable=True), + Column("trigger", Text, nullable=False), + *_execution_columns("", required=True), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + CheckConstraint(f"year BETWEEN {FIRST_YEAR} AND {LAST_YEAR}", name="year"), + CheckConstraint("attempt >= 1", name="attempt"), + CheckConstraint(f"trigger IN ({_values(RunTrigger)})", name="trigger"), + CheckConstraint( + "(simulation_kind = 'household' AND region_code IS NULL " + "AND dataset_id IS NULL) " + "OR (simulation_kind = 'economy' AND region_code IS NOT NULL " + "AND dataset_id IS NOT NULL)", + name="population_matches_kind", + ), + *_status_checks("", optional=False), + UniqueConstraint( + "simulation_id", + "tax_benefit_model_version_id", + "year", + "attempt", + name="uq_simulation_runs_attempt", + ), + # Report runs refer to a simulation run with its catalog version and year, + # so both runs of a report run share them. + UniqueConstraint( + "id", + "tax_benefit_model_version_id", + "year", + name="uq_simulation_runs_id_catalog_version_year", + ), + ForeignKeyConstraint( + ["simulation_id", "simulation_kind"], + [simulations.c.id, simulations.c.kind], + name="fk_simulation_runs_simulation", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["tax_benefit_model_version_id"], + [public_api.tax_benefit_model_versions.c.id], + name="fk_simulation_runs_catalog_version", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["tax_benefit_model_version_id", "region_code"], + [ + public_api.regions.c.tax_benefit_model_version_id, + public_api.regions.c.code, + ], + name="fk_simulation_runs_region", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["dataset_id", "tax_benefit_model_version_id"], + [ + public_api.datasets.c.id, + public_api.datasets.c.tax_benefit_model_version_id, + ], + name="fk_simulation_runs_dataset", + ondelete="RESTRICT", + ), +) +# A Public API delete of a catalog version, region or dataset checks these for +# references. +Index( + "ix_simulation_runs_catalog_version_region", + simulation_runs.c.tax_benefit_model_version_id, + simulation_runs.c.region_code, +) +Index("ix_simulation_runs_dataset_id", simulation_runs.c.dataset_id) +# Every run of one Modal call (a household report's two runs share one). +Index( + "ix_simulation_runs_modal_call_id", + simulation_runs.c.modal_call_id, + postgresql_where=simulation_runs.c.modal_call_id.is_not(None), +) +# The recording loop reads only running runs that are due a check, and +# dispatches that stopped. +Index( + "ix_simulation_runs_due_checks", + simulation_runs.c.next_check_at, + postgresql_where=simulation_runs.c.status == RunStatus.RUNNING.value, +) +Index( + "ix_simulation_runs_dispatching", + simulation_runs.c.updated_at, + postgresql_where=simulation_runs.c.status == RunStatus.DISPATCHING.value, +) + +_SIMULATION_RUN_KEY = [ + simulation_runs.c.id, + simulation_runs.c.tax_benefit_model_version_id, + simulation_runs.c.year, +] + +report_runs = Table( + "report_runs", + simulation_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("report_id", Uuid(as_uuid=True), nullable=False), + Column("report_kind", Text, nullable=False), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True), nullable=False), + Column("year", Integer, nullable=False), + Column("baseline_simulation_run_id", Uuid(as_uuid=True), nullable=False), + Column("reform_simulation_run_id", Uuid(as_uuid=True), nullable=True), + Column("trigger", Text, nullable=False), + *_execution_columns("comparison_", required=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + CheckConstraint(f"year BETWEEN {FIRST_YEAR} AND {LAST_YEAR}", name="year"), + CheckConstraint(f"trigger IN ({_values(RunTrigger)})", name="trigger"), + # An economy report run compares two simulation runs in its own comparison + # step; a household report run has no comparison step. + CheckConstraint( + "(report_kind = 'economy' AND reform_simulation_run_id IS NOT NULL " + "AND comparison_status IS NOT NULL) " + "OR (report_kind = 'household' AND comparison_status IS NULL " + "AND comparison_modal_call_id IS NULL " + "AND comparison_error_code IS NULL " + "AND comparison_dispatched_at IS NULL " + "AND comparison_next_check_at IS NULL " + "AND comparison_completed_at IS NULL " + "AND comparison_check_count = 0)", + name="comparison_matches_kind", + ), + *_status_checks("comparison_", optional=True), + ForeignKeyConstraint( + ["report_id", "report_kind"], + [reports.c.id, reports.c.kind], + name="fk_report_runs_report", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + [ + "baseline_simulation_run_id", + "tax_benefit_model_version_id", + "year", + ], + _SIMULATION_RUN_KEY, + name="fk_report_runs_baseline_simulation_run", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["reform_simulation_run_id", "tax_benefit_model_version_id", "year"], + _SIMULATION_RUN_KEY, + name="fk_report_runs_reform_simulation_run", + ondelete="RESTRICT", + ), +) +# A report's status is its newest report run's. +Index( + "ix_report_runs_report_id_created_at", + report_runs.c.report_id, + report_runs.c.created_at, +) +Index( + "ix_report_runs_baseline_simulation_run_id", + report_runs.c.baseline_simulation_run_id, +) +Index( + "ix_report_runs_reform_simulation_run_id", + report_runs.c.reform_simulation_run_id, +) +Index( + "ix_report_runs_due_comparison_checks", + report_runs.c.comparison_next_check_at, + postgresql_where=report_runs.c.comparison_status == RunStatus.RUNNING.value, +) +Index( + "ix_report_runs_comparisons_dispatching", + report_runs.c.updated_at, + postgresql_where=report_runs.c.comparison_status == RunStatus.DISPATCHING.value, +) + + +def bounded_error_message(message: str) -> str: + """Return ``message`` cut to the length the run tables store.""" + return message[:MAX_ERROR_MESSAGE_LENGTH] + + +@dataclass(frozen=True) +class NewSimulationRun: + """A simulation run to find or create. + + An economy run names the region and dataset rows its caller resolved in + the run's catalog version: the simulation's region code, and the dataset + the simulation names or, when it names none, the region's default dataset + in that version. The database checks that both belong to that version. A + household run names neither. + """ + + simulation_id: UUID + tax_benefit_model_version_id: UUID + year: int + trigger: RunTrigger + region_code: str | None = None + dataset_id: UUID | None = None + + def __post_init__(self) -> None: + """Reject a year outside the supported range without touching the database.""" + if not FIRST_YEAR <= self.year <= LAST_YEAR: + msg = f"year must be between {FIRST_YEAR} and {LAST_YEAR}" + raise ValueError(msg) + + +@dataclass(frozen=True) +class NewReportRun: + """A report run to create from two simulation runs that already exist. + + The baseline run must be a run of the report's baseline simulation, and + the reform run one of its reform simulation; an economy report run needs + both. They must share a catalog version and year, which the report run + takes from them. + """ + + report_id: UUID + baseline_simulation_run_id: UUID + trigger: RunTrigger + reform_simulation_run_id: UUID | None = None + + +def _record_values(cls: type, row: RowMapping) -> dict[str, Any]: + return {field.name: row[field.name] for field in fields(cls)} + + +@dataclass(frozen=True) +class SimulationRunRecord: + """One ``simulation.simulation_runs`` row.""" + + id: UUID + simulation_id: UUID + simulation_kind: SimulationKind + tax_benefit_model_version_id: UUID + year: int + attempt: int + region_code: str | None + dataset_id: UUID | None + trigger: RunTrigger + status: RunStatus + modal_call_id: str | None + error_code: str | None + error_message: str | None + check_count: int + dispatched_at: datetime | None + next_check_at: datetime | None + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + @property + def finished(self) -> bool: + """Whether the run has succeeded or failed.""" + return self.status in FINISHED_STATUSES + + @classmethod + def from_row(cls, row: RowMapping) -> Self: + """Build the record from a selected ``simulation_runs`` row.""" + values = _record_values(cls, row) + values["simulation_kind"] = SimulationKind(values["simulation_kind"]) + values["trigger"] = RunTrigger(values["trigger"]) + values["status"] = RunStatus(values["status"]) + return cls(**values) + + +@dataclass(frozen=True) +class ReportRunRecord: + """One ``simulation.report_runs`` row. + + The comparison fields are None for a household report run, which has no + comparison step. + """ + + id: UUID + report_id: UUID + report_kind: ReportKind + tax_benefit_model_version_id: UUID + year: int + baseline_simulation_run_id: UUID + reform_simulation_run_id: UUID | None + trigger: RunTrigger + comparison_status: RunStatus | None + comparison_modal_call_id: str | None + comparison_error_code: str | None + comparison_error_message: str | None + comparison_check_count: int + comparison_dispatched_at: datetime | None + comparison_next_check_at: datetime | None + comparison_completed_at: datetime | None + created_at: datetime + updated_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> Self: + """Build the record from a selected ``report_runs`` row.""" + values = _record_values(cls, row) + values["report_kind"] = ReportKind(values["report_kind"]) + values["trigger"] = RunTrigger(values["trigger"]) + if values["comparison_status"] is not None: + values["comparison_status"] = RunStatus(values["comparison_status"]) + return cls(**values) + + +@dataclass(frozen=True) +class StartedSimulationRun: + """The simulation run a start resolved to, and whether this call created it.""" + + run: SimulationRunRecord + created: bool diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py index 7e4080638..7ef7081c6 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -17,7 +17,7 @@ # The newest revision in migrations/versions. Each migration PR updates it, and # tests/test_schema.py fails when it differs from the chain's head. The # readiness check reports it next to the database's revision. -HEAD_REVISION = "0002" +HEAD_REVISION = "0003" NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py index 0ad0e7629..76f134c26 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py @@ -106,6 +106,9 @@ class SimulationKind(StrEnum): # Reports refer to a simulation together with its country and kind, so a # report can only compare simulations of its own country and kind. UniqueConstraint("id", "country", "kind"), + # Simulation runs refer to a simulation together with its kind, so a run + # names a region and dataset exactly when its simulation is an economy. + UniqueConstraint("id", "kind"), ForeignKeyConstraint( ["policy_id", "country"], [public_api.policies.c.id, public_api.policies.c.country_id], diff --git a/libs/policyengine-simulation-persistence/tests/public_api_support.py b/libs/policyengine-simulation-persistence/tests/public_api_support.py index cf144c68d..2ace7b50b 100644 --- a/libs/policyengine-simulation-persistence/tests/public_api_support.py +++ b/libs/policyengine-simulation-persistence/tests/public_api_support.py @@ -21,6 +21,24 @@ from sqlalchemy import Engine PUBLIC_API_TABLE_STATEMENTS = ( + "CREATE TABLE public.tax_benefit_model_versions (id uuid PRIMARY KEY)", + "CREATE TABLE public.datasets (" + " id uuid PRIMARY KEY," + " name varchar(255) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " CONSTRAINT uq_datasets_model_version_name" + " UNIQUE (tax_benefit_model_version_id, name)," + " CONSTRAINT uq_datasets_id_model_version" + " UNIQUE (id, tax_benefit_model_version_id))", + "CREATE TABLE public.regions (" + " id uuid PRIMARY KEY," + " code varchar(255) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " default_dataset_id uuid NOT NULL," + " CONSTRAINT uq_regions_model_version_code" + " UNIQUE (tax_benefit_model_version_id, code))", "CREATE TABLE public.policies (" " id uuid PRIMARY KEY," " country_id varchar(2) NOT NULL," @@ -31,7 +49,14 @@ " CONSTRAINT uq_households_id_country UNIQUE (id, country_id))", "CREATE TABLE public.dynamics (id uuid PRIMARY KEY)", ) -REFERENCED_TABLES = ("policies", "households", "dynamics") +REFERENCED_TABLES = ( + "tax_benefit_model_versions", + "datasets", + "regions", + "policies", + "households", + "dynamics", +) def superuser_engine(superuser_url: str, **options: object) -> Engine: @@ -65,13 +90,57 @@ def create_public_api_tables(superuser_url: str, *, grant_references: bool) -> N @dataclass(frozen=True) class PublicApiRows: - """Public API rows a simulation can refer to.""" + """Public API rows a simulation or run can refer to. + + Two catalog versions each have California (``state/ca``) with its own + default dataset, as each publication creates new rows. + """ us_policy_id: UUID uk_policy_id: UUID us_household_id: UUID uk_household_id: UUID dynamic_id: UUID + catalog_version_id: UUID + newer_catalog_version_id: UUID + dataset_id: UUID + newer_dataset_id: UUID + + +def _catalog_statements(rows: PublicApiRows) -> tuple[tuple[str, dict], ...]: + return ( + ( + "INSERT INTO public.tax_benefit_model_versions (id) VALUES " + "(:version), (:newer_version)", + { + "version": rows.catalog_version_id, + "newer_version": rows.newer_catalog_version_id, + }, + ), + ( + "INSERT INTO public.datasets (id, name, tax_benefit_model_version_id) " + "VALUES (:dataset, 'populace_us_2024', :version), " + "(:newer_dataset, 'populace_us_2024_acs_local', :newer_version)", + { + "dataset": rows.dataset_id, + "newer_dataset": rows.newer_dataset_id, + "version": rows.catalog_version_id, + "newer_version": rows.newer_catalog_version_id, + }, + ), + ( + "INSERT INTO public.regions " + "(id, code, tax_benefit_model_version_id, default_dataset_id) VALUES " + "(gen_random_uuid(), 'state/ca', :version, :dataset), " + "(gen_random_uuid(), 'state/ca', :newer_version, :newer_dataset)", + { + "version": rows.catalog_version_id, + "newer_version": rows.newer_catalog_version_id, + "dataset": rows.dataset_id, + "newer_dataset": rows.newer_dataset_id, + }, + ), + ) def insert_public_api_rows(superuser_url: str) -> PublicApiRows: @@ -82,8 +151,13 @@ def insert_public_api_rows(superuser_url: str) -> PublicApiRows: us_household_id=uuid4(), uk_household_id=uuid4(), dynamic_id=uuid4(), + catalog_version_id=uuid4(), + newer_catalog_version_id=uuid4(), + dataset_id=uuid4(), + newer_dataset_id=uuid4(), ) statements = ( + *_catalog_statements(rows), ( "INSERT INTO public.policies (id, country_id) VALUES " "(:us_policy, 'us'), (:uk_policy, 'uk')", diff --git a/libs/policyengine-simulation-persistence/tests/run_support.py b/libs/policyengine-simulation-persistence/tests/run_support.py new file mode 100644 index 000000000..99a2427bb --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/run_support.py @@ -0,0 +1,85 @@ +"""Builders and direct status changes for the run tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence import ( + NewSimulationRun, + RunStore, + RunTrigger, + create_simulation_engine, +) +from public_api_support import superuser_engine +from sqlalchemy import text + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_simulation_persistence import SimulationRecord + from public_api_support import PublicApiRows + + +def run_store(database) -> RunStore: + """Return a store that connects as the runtime role.""" + return RunStore(create_simulation_engine(database.runtime_url)) + + +def economy_run( + simulation: SimulationRecord, rows: PublicApiRows, **overrides: object +) -> NewSimulationRun: + """Return a 2026 run of an economy simulation on the first catalog version.""" + values: dict[str, object] = { + "simulation_id": simulation.id, + "tax_benefit_model_version_id": rows.catalog_version_id, + "year": 2026, + "trigger": RunTrigger.INITIAL, + "region_code": "state/ca", + "dataset_id": rows.dataset_id, + } + values.update(overrides) + return NewSimulationRun(**values) # type: ignore[arg-type] + + +def household_run( + simulation: SimulationRecord, rows: PublicApiRows, **overrides: object +) -> NewSimulationRun: + """Return a 2026 run of a household simulation on the first catalog version.""" + values: dict[str, object] = { + "simulation_id": simulation.id, + "tax_benefit_model_version_id": rows.catalog_version_id, + "year": 2026, + "trigger": RunTrigger.INITIAL, + } + values.update(overrides) + return NewSimulationRun(**values) # type: ignore[arg-type] + + +def set_simulation_run_status( + database, run_id: UUID, status: str, *, completed_days_ago: int = 0 +) -> None: + """Move a run to a status directly, as the superuser, for read tests.""" + finished = status in {"succeeded", "failed"} + engine = superuser_engine(database.superuser_url) + try: + with engine.begin() as connection: + connection.execute( + text( + "UPDATE simulation.simulation_runs SET status = :status, " + "modal_call_id = CASE WHEN :status IN ('running', 'succeeded') " + "THEN 'fc-test' ELSE modal_call_id END, " + "error_code = CASE WHEN :status = 'failed' " + "THEN 'test_failure' ELSE NULL END, " + "completed_at = CASE WHEN :finished " + "THEN now() - make_interval(days => :days) ELSE NULL END " + "WHERE id = :id" + ), + { + "status": status, + "finished": finished, + "days": completed_days_ago, + "id": run_id, + }, + ) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_runs.py b/libs/policyengine-simulation-persistence/tests/test_runs.py new file mode 100644 index 000000000..6ad236bf5 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_runs.py @@ -0,0 +1,495 @@ +"""simulation_runs and report_runs: executions of simulations and reports.""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, + NewReportRun, + ReportKind, + ReportRunRecord, + RunStatus, + RunTrigger, + SimulationRunRecord, + create_simulation_engine, +) +from public_api_support import delete_public_api_row +from report_support import ( + economy_pair, + economy_report, + household_baseline, + report_store, +) +from run_support import ( + economy_run, + household_run, + run_store, + set_simulation_run_status, +) +from simulation_support import ( + economy_simulation, + household_simulation, + simulation_store, +) +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError, ProgrammingError + +pytestmark = pytest.mark.postgres + + +def test_an_economy_run_records_the_region_and_dataset_it_resolved( + migrated_database, public_api_rows +) -> None: + _, reform = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + + started = store.start_simulation_run(economy_run(reform, public_api_rows)) + + run = started.run + assert started.created is True + assert isinstance(run, SimulationRunRecord) + assert run.simulation_id == reform.id + assert run.simulation_kind == "economy" + assert run.tax_benefit_model_version_id == public_api_rows.catalog_version_id + assert run.year == 2026 + assert run.attempt == 1 + assert run.region_code == "state/ca" + assert run.dataset_id == public_api_rows.dataset_id + assert run.status is RunStatus.PENDING + assert run.trigger is RunTrigger.INITIAL + assert run.modal_call_id is None + assert run.completed_at is None + assert store.get_simulation_run(run.id) == run + + +def test_a_run_that_exists_is_reused(migrated_database, public_api_rows) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + again = store.start_simulation_run( + economy_run(baseline, public_api_rows, trigger=RunTrigger.MANUAL) + ) + + assert again.created is False + assert again.run == first.run + + +def test_a_forced_rerun_adds_the_next_attempt( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + forced = store.start_simulation_run( + economy_run(baseline, public_api_rows, trigger=RunTrigger.MANUAL), force=True + ) + + assert forced.created is True + assert forced.run.attempt == first.run.attempt + 1 + assert forced.run.trigger is RunTrigger.MANUAL + latest = store.latest_simulation_run( + baseline.id, public_api_rows.catalog_version_id, 2026 + ) + assert latest == forced.run + + +def test_a_failed_run_is_not_reused(migrated_database, public_api_rows) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + set_simulation_run_status(migrated_database, first.run.id, "failed") + + again = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + assert again.created is True + assert again.run.attempt == 2 + + +@pytest.mark.parametrize( + ("completed_days_ago", "reused"), + [(27, True), (29, False)], + ids=["within-28-days", "older-than-28-days"], +) +def test_a_succeeded_run_is_reused_only_within_28_days( + migrated_database, public_api_rows, completed_days_ago, reused +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + set_simulation_run_status( + migrated_database, + first.run.id, + "succeeded", + completed_days_ago=completed_days_ago, + ) + + again = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + assert again.created is not reused + assert again.run.attempt == (1 if reused else 2) + assert timedelta(days=28) == SUCCEEDED_RUN_REUSE_WINDOW + + +def test_each_catalog_version_and_year_has_its_own_runs( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + newer = store.start_simulation_run( + economy_run( + baseline, + public_api_rows, + tax_benefit_model_version_id=public_api_rows.newer_catalog_version_id, + dataset_id=public_api_rows.newer_dataset_id, + ) + ) + next_year = store.start_simulation_run( + economy_run(baseline, public_api_rows, year=2027) + ) + + assert len({first.run.id, newer.run.id, next_year.run.id}) == 3 + assert newer.created is True + assert next_year.created is True + assert newer.run.attempt == next_year.run.attempt == 1 + + +@pytest.mark.parametrize( + ("overrides", "constraint"), + [ + ({"dataset_attribute": "newer_dataset_id"}, "fk_simulation_runs_dataset"), + ({"region_code": None}, "population_matches_kind"), + ({"dataset_attribute": None}, "population_matches_kind"), + ], + ids=[ + "dataset-of-another-version", + "economy-without-region", + "economy-without-dataset", + ], +) +def test_an_economy_run_uses_only_its_own_versions_dataset( + migrated_database, public_api_rows, overrides, constraint +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + values = dict(overrides) + if "dataset_attribute" in values: + attribute = values.pop("dataset_attribute") + values["dataset_id"] = ( + None if attribute is None else getattr(public_api_rows, attribute) + ) + + with pytest.raises(IntegrityError, match=constraint): + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows, **values) + ) + + +def test_an_economy_run_needs_its_region_in_its_catalog_version( + migrated_database, public_api_rows +) -> None: + new_york = simulation_store(migrated_database).create( + economy_simulation(public_api_rows, region_code="state/ny") + ) + + with pytest.raises(IntegrityError, match="fk_simulation_runs_region"): + run_store(migrated_database).start_simulation_run( + economy_run(new_york, public_api_rows, region_code="state/ny") + ) + + +def test_an_economy_run_uses_its_simulations_region( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(ValueError, match="region_code"): + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows, region_code="state/ny") + ) + + +def test_a_household_run_has_no_region_or_dataset( + migrated_database, public_api_rows +) -> None: + simulation = household_baseline(migrated_database, public_api_rows) + store = run_store(migrated_database) + + started = store.start_simulation_run(household_run(simulation, public_api_rows)) + assert started.run.simulation_kind == "household" + assert started.run.region_code is None + + with pytest.raises(IntegrityError, match="population_matches_kind"): + store.start_simulation_run( + household_run( + simulation, + public_api_rows, + year=2027, + region_code="state/ca", + dataset_id=public_api_rows.dataset_id, + ) + ) + + +def test_a_run_of_an_unknown_simulation_is_refused( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(LookupError, match="simulation"): + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows, simulation_id=uuid4()) + ) + + +def test_the_public_api_cannot_delete_a_dataset_a_run_used( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows) + ) + + with pytest.raises(IntegrityError, match="violates foreign key constraint"): + delete_public_api_row( + migrated_database.superuser_url, "datasets", public_api_rows.dataset_id + ) + + +def _economy_report_with_runs(database, rows): + baseline, reform = economy_pair(database, rows) + report = report_store(database).create(economy_report(baseline, reform)) + store = run_store(database) + baseline_run = store.start_simulation_run(economy_run(baseline, rows)).run + reform_run = store.start_simulation_run(economy_run(reform, rows)).run + return report, baseline_run, reform_run + + +def test_an_economy_report_run_joins_two_runs_and_waits_for_its_comparison( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + + report_run = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + assert isinstance(report_run, ReportRunRecord) + assert report_run.report_kind == "economy" + assert report_run.tax_benefit_model_version_id == ( + public_api_rows.catalog_version_id + ) + assert report_run.year == 2026 + assert report_run.comparison_status is RunStatus.PENDING + assert report_run.comparison_modal_call_id is None + assert store.get_report_run(report_run.id) == report_run + assert store.latest_report_run(report.id) == report_run + + +def test_both_runs_of_a_report_run_share_its_version_and_year( + migrated_database, public_api_rows +) -> None: + report, baseline_run, _ = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + reform = report_store(migrated_database).get(report.id) + assert reform is not None + later_reform_run = store.start_simulation_run( + economy_run( + simulation_store(migrated_database).get(reform.reform_simulation_id), + public_api_rows, + year=2027, + ) + ).run + + with pytest.raises(IntegrityError, match="fk_report_runs_reform_simulation_run"): + store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=later_reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +def test_a_report_run_uses_only_runs_of_the_reports_simulations( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + + with pytest.raises(ValueError, match="report's simulations"): + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=reform_run.id, + reform_simulation_run_id=baseline_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +def test_an_economy_report_run_needs_its_reform_run( + migrated_database, public_api_rows +) -> None: + report, baseline_run, _ = _economy_report_with_runs( + migrated_database, public_api_rows + ) + + with pytest.raises(ValueError, match="report's simulations"): + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +def test_a_household_report_run_has_no_comparison_step( + migrated_database, public_api_rows +) -> None: + store = simulation_store(migrated_database) + baseline = household_baseline(migrated_database, public_api_rows) + reform = store.create( + household_simulation(public_api_rows, policy_id=public_api_rows.us_policy_id) + ) + report = report_store(migrated_database).create( + economy_report(baseline, reform, kind=ReportKind.HOUSEHOLD) + ) + runs = run_store(migrated_database) + baseline_run = runs.start_simulation_run( + household_run(baseline, public_api_rows) + ).run + reform_run = runs.start_simulation_run(household_run(reform, public_api_rows)).run + + report_run = runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.SYSTEM, + ) + ) + + assert report_run.report_kind == "household" + assert report_run.comparison_status is None + assert report_run.trigger is RunTrigger.SYSTEM + + +def test_a_single_simulation_household_report_run_has_no_reform_run( + migrated_database, public_api_rows +) -> None: + baseline = household_baseline(migrated_database, public_api_rows) + report = report_store(migrated_database).create( + economy_report( + baseline, baseline, kind=ReportKind.HOUSEHOLD, reform_simulation_id=None + ) + ) + runs = run_store(migrated_database) + baseline_run = runs.start_simulation_run( + household_run(baseline, public_api_rows) + ).run + + report_run = runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + assert report_run.reform_simulation_run_id is None + + +def test_the_newest_report_run_is_the_reports_current_run( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + new_report_run = NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + store.create_report_run(new_report_run) + + rerun = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.MANUAL, + ) + ) + + assert store.latest_report_run(report.id) == rerun + assert store.latest_report_run(uuid4()) is None + + +def test_a_report_run_of_an_unknown_report_is_refused( + migrated_database, public_api_rows +) -> None: + _, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + + with pytest.raises(LookupError, match="report"): + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=uuid4(), + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +@pytest.mark.parametrize("table_name", ["simulation_runs", "report_runs"]) +def test_the_runtime_role_updates_runs_but_cannot_delete_them( + migrated_database, public_api_rows, table_name +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + engine = create_simulation_engine(migrated_database.runtime_url) + try: + with engine.begin() as connection: + updated = connection.execute( + text(f"UPDATE simulation.{table_name} SET updated_at = now()") # noqa: S608 # fixed table names + ) + assert updated.rowcount >= 1 + with ( + pytest.raises(ProgrammingError, match="permission denied"), + engine.begin() as connection, + ): + connection.execute(text(f"DELETE FROM simulation.{table_name}")) # noqa: S608 # fixed table names + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py index 8ac5b7b05..4a6f42383 100644 --- a/libs/policyengine-simulation-persistence/tests/test_schema.py +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -116,4 +116,9 @@ def test_importing_the_package_registers_every_table() -> None: importlib.import_module(f"policyengine_simulation_persistence.{module.name}") assert set(simulation_metadata.tables) == package_tables - assert {"simulation.simulations", "simulation.reports"} <= package_tables + assert { + "simulation.simulations", + "simulation.reports", + "simulation.simulation_runs", + "simulation.report_runs", + } <= package_tables diff --git a/migrations/versions/0003_simulation_runs_and_report_runs.py b/migrations/versions/0003_simulation_runs_and_report_runs.py new file mode 100644 index 000000000..1225bb99f --- /dev/null +++ b/migrations/versions/0003_simulation_runs_and_report_runs.py @@ -0,0 +1,328 @@ +"""Add simulation runs and report runs. + +``simulation_runs`` records each execution of a simulation for a catalog +version and year: the region and dataset rows it resolved in that version, its +status, Modal call, timings, error, trigger and attempt number, unique per +simulation, catalog version, year and attempt. Composite foreign keys through +the Public API's ``uq_regions_model_version_code`` and +``uq_datasets_id_model_version`` keep a run on its own version's rows, and need +``REFERENCES`` on ``tax_benefit_model_versions``, ``regions`` and ``datasets`` +for the migrator role. ``report_runs`` records each calculation of a report: +two simulation runs of one catalog version and year and, for an economy +report, the comparison step's status and Modal call. The runtime role may read, +insert and update both tables. Nothing writes them yet. + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-10-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + +SCHEMA = "simulation" +STATUSES = "'pending', 'dispatching', 'running', 'succeeded', 'failed'" +TRIGGERS = "'initial', 'manual', 'system'" + + +def _public_api(table_and_column: str) -> str: + return f"public.{table_and_column}" + + +def _execution_columns(prefix: str, *, required: bool) -> list[sa.Column]: + return [ + sa.Column(f"{prefix}status", sa.Text(), nullable=not required), + sa.Column(f"{prefix}modal_call_id", sa.Text(), nullable=True), + sa.Column(f"{prefix}error_code", sa.Text(), nullable=True), + sa.Column(f"{prefix}error_message", sa.Text(), nullable=True), + sa.Column( + f"{prefix}check_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + sa.Column(f"{prefix}dispatched_at", sa.DateTime(timezone=True), nullable=True), + sa.Column(f"{prefix}next_check_at", sa.DateTime(timezone=True), nullable=True), + sa.Column(f"{prefix}completed_at", sa.DateTime(timezone=True), nullable=True), + ] + + +def _status_checks( + table: str, prefix: str, *, optional: bool +) -> list[sa.CheckConstraint]: + status = f"{prefix}status" + unless_absent = f"{status} IS NULL OR " if optional else "" + return [ + sa.CheckConstraint( + f"{unless_absent}{status} IN ({STATUSES})", + name=op.f(f"ck_{table}_{prefix}status"), + ), + sa.CheckConstraint( + f"{unless_absent}" + f"({status} IN ('pending', 'dispatching') " + f"AND {prefix}modal_call_id IS NULL) " + f"OR ({status} IN ('running', 'succeeded') " + f"AND {prefix}modal_call_id IS NOT NULL) " + f"OR {status} = 'failed'", + name=op.f(f"ck_{table}_{prefix}modal_call"), + ), + sa.CheckConstraint( + f"{unless_absent}" + f"({status} IN ('succeeded', 'failed')) " + f"= ({prefix}completed_at IS NOT NULL)", + name=op.f(f"ck_{table}_{prefix}completed_at"), + ), + sa.CheckConstraint( + f"{unless_absent}({status} = 'failed') = ({prefix}error_code IS NOT NULL)", + name=op.f(f"ck_{table}_{prefix}error"), + ), + sa.CheckConstraint( + f"{unless_absent}" + f"({status} = 'running') = ({prefix}next_check_at IS NOT NULL)", + name=op.f(f"ck_{table}_{prefix}next_check_at"), + ), + sa.CheckConstraint( + f"{prefix}check_count >= 0", name=op.f(f"ck_{table}_{prefix}check_count") + ), + ] + + +def _create_simulation_runs() -> None: + op.create_unique_constraint( + "uq_simulations_id_kind", "simulations", ["id", "kind"], schema=SCHEMA + ) + op.create_table( + "simulation_runs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("simulation_id", sa.Uuid(), nullable=False), + sa.Column("simulation_kind", sa.Text(), nullable=False), + sa.Column("tax_benefit_model_version_id", sa.Uuid(), nullable=False), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("region_code", sa.String(length=255), nullable=True), + sa.Column("dataset_id", sa.Uuid(), nullable=True), + sa.Column("trigger", sa.Text(), nullable=False), + *_execution_columns("", required=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "year BETWEEN 1900 AND 2200", name=op.f("ck_simulation_runs_year") + ), + sa.CheckConstraint("attempt >= 1", name=op.f("ck_simulation_runs_attempt")), + sa.CheckConstraint( + f"trigger IN ({TRIGGERS})", name=op.f("ck_simulation_runs_trigger") + ), + sa.CheckConstraint( + "(simulation_kind = 'household' AND region_code IS NULL " + "AND dataset_id IS NULL) " + "OR (simulation_kind = 'economy' AND region_code IS NOT NULL " + "AND dataset_id IS NOT NULL)", + name=op.f("ck_simulation_runs_population_matches_kind"), + ), + *_status_checks("simulation_runs", "", optional=False), + sa.ForeignKeyConstraint( + ["simulation_id", "simulation_kind"], + [f"{SCHEMA}.simulations.id", f"{SCHEMA}.simulations.kind"], + name="fk_simulation_runs_simulation", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["tax_benefit_model_version_id"], + [_public_api("tax_benefit_model_versions.id")], + name="fk_simulation_runs_catalog_version", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["tax_benefit_model_version_id", "region_code"], + [ + _public_api("regions.tax_benefit_model_version_id"), + _public_api("regions.code"), + ], + name="fk_simulation_runs_region", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["dataset_id", "tax_benefit_model_version_id"], + [ + _public_api("datasets.id"), + _public_api("datasets.tax_benefit_model_version_id"), + ], + name="fk_simulation_runs_dataset", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name="pk_simulation_runs"), + sa.UniqueConstraint( + "simulation_id", + "tax_benefit_model_version_id", + "year", + "attempt", + name="uq_simulation_runs_attempt", + ), + sa.UniqueConstraint( + "id", + "tax_benefit_model_version_id", + "year", + name="uq_simulation_runs_id_catalog_version_year", + ), + schema=SCHEMA, + ) + op.create_index( + "ix_simulation_runs_catalog_version_region", + "simulation_runs", + ["tax_benefit_model_version_id", "region_code"], + schema=SCHEMA, + ) + op.create_index( + "ix_simulation_runs_dataset_id", + "simulation_runs", + ["dataset_id"], + schema=SCHEMA, + ) + op.create_index( + "ix_simulation_runs_modal_call_id", + "simulation_runs", + ["modal_call_id"], + schema=SCHEMA, + postgresql_where=sa.text("modal_call_id IS NOT NULL"), + ) + op.create_index( + "ix_simulation_runs_due_checks", + "simulation_runs", + ["next_check_at"], + schema=SCHEMA, + postgresql_where=sa.text("status = 'running'"), + ) + op.create_index( + "ix_simulation_runs_dispatching", + "simulation_runs", + ["updated_at"], + schema=SCHEMA, + postgresql_where=sa.text("status = 'dispatching'"), + ) + + +def _create_report_runs() -> None: + op.create_unique_constraint( + "uq_reports_id_kind", "reports", ["id", "kind"], schema=SCHEMA + ) + simulation_run_key = [ + f"{SCHEMA}.simulation_runs.id", + f"{SCHEMA}.simulation_runs.tax_benefit_model_version_id", + f"{SCHEMA}.simulation_runs.year", + ] + op.create_table( + "report_runs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("report_id", sa.Uuid(), nullable=False), + sa.Column("report_kind", sa.Text(), nullable=False), + sa.Column("tax_benefit_model_version_id", sa.Uuid(), nullable=False), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("baseline_simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("reform_simulation_run_id", sa.Uuid(), nullable=True), + sa.Column("trigger", sa.Text(), nullable=False), + *_execution_columns("comparison_", required=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "year BETWEEN 1900 AND 2200", name=op.f("ck_report_runs_year") + ), + sa.CheckConstraint( + f"trigger IN ({TRIGGERS})", name=op.f("ck_report_runs_trigger") + ), + sa.CheckConstraint( + "(report_kind = 'economy' AND reform_simulation_run_id IS NOT NULL " + "AND comparison_status IS NOT NULL) " + "OR (report_kind = 'household' AND comparison_status IS NULL " + "AND comparison_modal_call_id IS NULL " + "AND comparison_error_code IS NULL " + "AND comparison_dispatched_at IS NULL " + "AND comparison_next_check_at IS NULL " + "AND comparison_completed_at IS NULL " + "AND comparison_check_count = 0)", + name=op.f("ck_report_runs_comparison_matches_kind"), + ), + *_status_checks("report_runs", "comparison_", optional=True), + sa.ForeignKeyConstraint( + ["report_id", "report_kind"], + [f"{SCHEMA}.reports.id", f"{SCHEMA}.reports.kind"], + name="fk_report_runs_report", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["baseline_simulation_run_id", "tax_benefit_model_version_id", "year"], + simulation_run_key, + name="fk_report_runs_baseline_simulation_run", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["reform_simulation_run_id", "tax_benefit_model_version_id", "year"], + simulation_run_key, + name="fk_report_runs_reform_simulation_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name="pk_report_runs"), + schema=SCHEMA, + ) + op.create_index( + "ix_report_runs_report_id_created_at", + "report_runs", + ["report_id", "created_at"], + schema=SCHEMA, + ) + for column in ("baseline_simulation_run_id", "reform_simulation_run_id"): + op.create_index( + f"ix_report_runs_{column}", "report_runs", [column], schema=SCHEMA + ) + op.create_index( + "ix_report_runs_due_comparison_checks", + "report_runs", + ["comparison_next_check_at"], + schema=SCHEMA, + postgresql_where=sa.text("comparison_status = 'running'"), + ) + op.create_index( + "ix_report_runs_comparisons_dispatching", + "report_runs", + ["updated_at"], + schema=SCHEMA, + postgresql_where=sa.text("comparison_status = 'dispatching'"), + ) + + +def upgrade() -> None: + """Create both tables; the default privileges let the runtime role use them.""" + _create_simulation_runs() + _create_report_runs() + + +def downgrade() -> None: + """Drop the tables ``upgrade`` created, with their indexes and keys.""" + for index in ( + "ix_report_runs_comparisons_dispatching", + "ix_report_runs_due_comparison_checks", + "ix_report_runs_reform_simulation_run_id", + "ix_report_runs_baseline_simulation_run_id", + "ix_report_runs_report_id_created_at", + ): + op.drop_index(index, table_name="report_runs", schema=SCHEMA) + op.drop_table("report_runs", schema=SCHEMA) + op.drop_constraint("uq_reports_id_kind", "reports", schema=SCHEMA, type_="unique") + for index in ( + "ix_simulation_runs_dispatching", + "ix_simulation_runs_due_checks", + "ix_simulation_runs_modal_call_id", + "ix_simulation_runs_dataset_id", + "ix_simulation_runs_catalog_version_region", + ): + op.drop_index(index, table_name="simulation_runs", schema=SCHEMA) + op.drop_table("simulation_runs", schema=SCHEMA) + op.drop_constraint( + "uq_simulations_id_kind", "simulations", schema=SCHEMA, type_="unique" + ) From 289ff988ba768de56d697be6e75cd8d66f05ff32 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:26:53 +0400 Subject: [PATCH 045/124] Change run statuses only with conditional updates, so a second writer changes nothing RunTransitions makes every status change of a simulation run, or of an economy report run's comparison step, one UPDATE conditional on the expected status. When two Simulation Entrypoint instances act on the same run, PostgreSQL makes the second wait for the first's row lock, re-checks the condition and changes nothing; the methods return whether they changed anything. - claim_simulation_dispatch moves runs from pending to dispatching; only the instance that wins starts the Modal call. record_simulation_dispatched records the call ID and the first check time (running), and record_simulation_dispatch_failure fails a run whose call Modal refused. - The runs one Modal call computes, such as a household report's baseline and reform, are passed together and change all together or not at all. - claim_due_simulation_calls and claim_due_comparisons claim running calls that are due a check by moving their next check time one lease ahead (SELECT ... FOR UPDATE SKIP LOCKED), so instances do not check the same call at once, and a call becomes due again if the claiming instance stops. - complete_simulation_call and complete_comparison mark the call's runs succeeded and run the caller's result writer in the same transaction; if the writer raises, nothing is recorded. fail_simulation_call and fail_comparison record an error code and a message bounded to 2,000 characters. - A comparison step can be claimed only once both of its simulation runs have succeeded. - fail_abandoned_dispatches fails runs and comparison steps left in dispatching since a given time (dispatch_abandoned), so they can be submitted again. Tests cover each change, a repeated change, eight concurrent dispatch claims (exactly one wins) and four instances claiming due calls (disjoint sets), on PostgreSQL. Co-Authored-By: Claude Opus 5.5 --- .../__init__.py | 12 + .../run_transitions.py | 566 ++++++++++++++++++ .../tests/test_run_transitions.py | 491 +++++++++++++++ .../tests/transition_support.py | 49 ++ 4 files changed, 1118 insertions(+) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_run_transitions.py create mode 100644 libs/policyengine-simulation-persistence/tests/transition_support.py diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index a83a0d3e5..be715f3e5 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -19,6 +19,13 @@ SUCCEEDED_RUN_REUSE_WINDOW, RunStore, ) +from policyengine_simulation_persistence.run_transitions import ( + AbandonedDispatches, + DueSimulationCall, + RunError, + RunErrorCode, + RunTransitions, +) from policyengine_simulation_persistence.runs import ( NewReportRun, NewSimulationRun, @@ -48,6 +55,8 @@ "RUNTIME_ROLE", "SIMULATION_SCHEMA", "SUCCEEDED_RUN_REUSE_WINDOW", + "AbandonedDispatches", + "DueSimulationCall", "NewReport", "NewReportRun", "NewSimulation", @@ -56,8 +65,11 @@ "ReportRecord", "ReportRunRecord", "ReportStore", + "RunError", + "RunErrorCode", "RunStatus", "RunStore", + "RunTransitions", "RunTrigger", "SimulationKind", "SimulationRecord", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py new file mode 100644 index 000000000..39c6ee2c8 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -0,0 +1,566 @@ +"""Conditional status changes for simulation runs and comparison steps. + +Every change is one ``UPDATE ... WHERE = ``. When two +Simulation Entrypoint instances act on the same run at once, PostgreSQL makes +the second wait for the first's row lock, then re-checks the condition and +changes nothing. So no lease table or advisory lock is needed: + +- only the instance that moves a run from ``pending`` to ``dispatching`` + starts its Modal call, and it then records the call ID (``running``); +- the runs that share one Modal call, such as a household report's baseline + and reform, change status together, all or none; +- the recording loop claims a running call's next check by moving its + ``next_check_at`` forward, so instances do not check the same call at once, + and only one of them records its outcome; +- a call's results are written in the same transaction that marks its runs + ``succeeded``, so a run never succeeds without its results; +- a run left in ``dispatching`` (the instance stopped between claiming it and + recording its call) is failed after a while, so it can be submitted again. + +An economy report run's comparison step follows the same statuses on its own +``comparison_*`` columns, and may start only once both of its simulation runs +have succeeded. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING + +from sqlalchemy import and_, exists, select, update + +from policyengine_simulation_persistence.run_store import utc_now +from policyengine_simulation_persistence.runs import ( + ReportRunRecord, + RunStatus, + SimulationRunRecord, + bounded_error_message, + report_runs, + simulation_runs, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Collection + from datetime import datetime, timedelta + from uuid import UUID + + from sqlalchemy import ColumnElement, Connection, Engine + + type SimulationResultWriter = Callable[ + [Connection, tuple[SimulationRunRecord, ...]], None + ] + type ComparisonResultWriter = Callable[[Connection, ReportRunRecord], None] + + +class RunErrorCode(StrEnum): + """Why a run or comparison step failed.""" + + # Modal refused the call or never acknowledged it. + DISPATCH_FAILED = "dispatch_failed" + # The instance stopped between claiming the dispatch and recording the call. + DISPATCH_ABANDONED = "dispatch_abandoned" + # The Modal function raised. + WORKER_ERROR = "worker_error" + WORKER_TIMEOUT = "worker_timeout" + # Modal no longer holds the output, or does not know the call. + RESULT_EXPIRED = "result_expired" + CALL_NOT_FOUND = "call_not_found" + # The output failed validation against its result model. + INVALID_RESULT = "invalid_result" + # No recorder handles results of this kind of run. + NO_RESULT_RECORDER = "no_result_recorder" + + +@dataclass(frozen=True) +class RunError: + """A failure to record on a run; the message must carry no user data.""" + + code: RunErrorCode + message: str + + +@dataclass(frozen=True) +class DueSimulationCall: + """A running Modal call whose runs this instance claimed to check now.""" + + modal_call_id: str + runs: tuple[SimulationRunRecord, ...] + + +@dataclass(frozen=True) +class AbandonedDispatches: + """Runs and comparison steps failed because their dispatch never finished.""" + + simulation_run_ids: tuple[UUID, ...] + report_run_ids: tuple[UUID, ...] + + +class RunTransitions: + """Conditional status writes; each returns whether it changed anything.""" + + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime] = utc_now, + ) -> None: + """Use ``engine`` (the runtime role) and ``clock`` for every timestamp.""" + self.engine = engine + self._clock = clock + + # --- Simulation runs --------------------------------------------------- + + def _change_simulation_runs( + self, + run_ids: Collection[UUID], + *, + expected: RunStatus, + values: dict[str, object], + ) -> bool: + """Change every run in ``run_ids`` from ``expected``, or none of them.""" + wanted = set(run_ids) + if not wanted: + msg = "a status change needs at least one run" + raise ValueError(msg) + with self.engine.connect() as connection: + transaction = connection.begin() + changed = connection.execute( + update(simulation_runs) + .where( + simulation_runs.c.id.in_(wanted), + simulation_runs.c.status == expected.value, + ) + .values(updated_at=self._clock(), **values) + .returning(simulation_runs.c.id) + ).scalars() + if set(changed) != wanted: + transaction.rollback() + return False + transaction.commit() + return True + + def claim_simulation_dispatch(self, run_ids: Collection[UUID]) -> bool: + """Move pending runs to ``dispatching``; only the winner starts the call. + + ``run_ids`` are the runs one Modal call computes: one run, or a + household report's baseline and reform. All must be pending. + """ + return self._change_simulation_runs( + run_ids, + expected=RunStatus.PENDING, + values={"status": RunStatus.DISPATCHING.value}, + ) + + def record_simulation_dispatched( + self, + run_ids: Collection[UUID], + *, + modal_call_id: str, + first_check_at: datetime, + ) -> bool: + """Record the acknowledged Modal call and move the runs to ``running``.""" + return self._change_simulation_runs( + run_ids, + expected=RunStatus.DISPATCHING, + values={ + "status": RunStatus.RUNNING.value, + "modal_call_id": modal_call_id, + "dispatched_at": self._clock(), + "next_check_at": first_check_at, + }, + ) + + def record_simulation_dispatch_failure( + self, run_ids: Collection[UUID], error: RunError + ) -> bool: + """Fail dispatching runs whose Modal call was refused.""" + return self._change_simulation_runs( + run_ids, + expected=RunStatus.DISPATCHING, + values={ + "status": RunStatus.FAILED.value, + "error_code": error.code.value, + "error_message": bounded_error_message(error.message), + "completed_at": self._clock(), + }, + ) + + def claim_due_simulation_calls( + self, *, limit: int, lease: timedelta + ) -> list[DueSimulationCall]: + """Claim up to ``limit`` running runs that are due a check, by call. + + Each claimed run's next check moves ``lease`` ahead, so no other + instance checks it until the lease ends; if this instance stops, the + run becomes due again then. Rows another instance holds are skipped. + Two runs of one call are normally claimed together; if the limit + splits them, two instances may ask Modal about the same call, and the + conditional writes still record its outcome once. + """ + now = self._clock() + due = ( + select(simulation_runs.c.id) + .where( + simulation_runs.c.status == RunStatus.RUNNING.value, + simulation_runs.c.next_check_at <= now, + ) + .order_by(simulation_runs.c.next_check_at) + .limit(limit) + .with_for_update(skip_locked=True) + .scalar_subquery() + ) + with self.engine.begin() as connection: + rows = ( + connection.execute( + update(simulation_runs) + .where( + simulation_runs.c.id.in_(due), + simulation_runs.c.status == RunStatus.RUNNING.value, + simulation_runs.c.next_check_at <= now, + ) + .values( + next_check_at=now + lease, + check_count=simulation_runs.c.check_count + 1, + updated_at=now, + ) + .returning(*simulation_runs.c) + ) + .mappings() + .all() + ) + runs_by_call: dict[str, list[SimulationRunRecord]] = defaultdict(list) + for row in rows: + run = SimulationRunRecord.from_row(row) + if run.modal_call_id is not None: + runs_by_call[run.modal_call_id].append(run) + return [ + DueSimulationCall(modal_call_id=call_id, runs=tuple(runs)) + for call_id, runs in sorted(runs_by_call.items()) + ] + + def _change_simulation_call( + self, + connection: Connection, + modal_call_id: str, + values: dict[str, object], + ) -> tuple[SimulationRunRecord, ...]: + rows = ( + connection.execute( + update(simulation_runs) + .where( + simulation_runs.c.modal_call_id == modal_call_id, + simulation_runs.c.status == RunStatus.RUNNING.value, + ) + .values(updated_at=self._clock(), **values) + .returning(*simulation_runs.c) + ) + .mappings() + .all() + ) + return tuple(SimulationRunRecord.from_row(row) for row in rows) + + def record_simulation_call_running( + self, modal_call_id: str, *, next_check_at: datetime + ) -> bool: + """Set the next check of a call's runs that are still running.""" + with self.engine.begin() as connection: + return bool( + self._change_simulation_call( + connection, modal_call_id, {"next_check_at": next_check_at} + ) + ) + + def complete_simulation_call( + self, modal_call_id: str, write_results: SimulationResultWriter + ) -> bool: + """Mark a call's running runs succeeded and write their results. + + ``write_results`` receives the transaction and the succeeded runs, and + writes their result rows. If it raises, nothing is recorded and the + exception propagates. Return False, writing nothing, when no run of + the call is still running. + """ + with self.engine.begin() as connection: + runs = self._change_simulation_call( + connection, + modal_call_id, + { + "status": RunStatus.SUCCEEDED.value, + "completed_at": self._clock(), + "next_check_at": None, + }, + ) + if not runs: + return False + write_results(connection, runs) + return True + + def fail_simulation_call(self, modal_call_id: str, error: RunError) -> bool: + """Mark a call's running runs failed; False if none is still running.""" + with self.engine.begin() as connection: + return bool( + self._change_simulation_call( + connection, + modal_call_id, + { + "status": RunStatus.FAILED.value, + "error_code": error.code.value, + "error_message": bounded_error_message(error.message), + "completed_at": self._clock(), + "next_check_at": None, + }, + ) + ) + + # --- Comparison steps of economy report runs ---------------------------- + + def _change_comparison( + self, + connection: Connection, + report_run_id: UUID, + *, + expected: RunStatus, + values: dict[str, object], + conditions: tuple[ColumnElement[bool], ...] = (), + ) -> ReportRunRecord | None: + row = ( + connection.execute( + update(report_runs) + .where( + report_runs.c.id == report_run_id, + report_runs.c.comparison_status == expected.value, + *conditions, + ) + .values(updated_at=self._clock(), **values) + .returning(*report_runs.c) + ) + .mappings() + .first() + ) + return None if row is None else ReportRunRecord.from_row(row) + + def claim_comparison_dispatch(self, report_run_id: UUID) -> bool: + """Move a pending comparison step to ``dispatching``. + + Only the winner starts the comparison's Modal call. The step can start + only once both of its simulation runs have succeeded. + """ + baseline = simulation_runs.alias("baseline_run") + reform = simulation_runs.alias("reform_run") + both_runs_succeeded = ( + exists().where( + and_( + baseline.c.id == report_runs.c.baseline_simulation_run_id, + baseline.c.status == RunStatus.SUCCEEDED.value, + ) + ), + exists().where( + and_( + reform.c.id == report_runs.c.reform_simulation_run_id, + reform.c.status == RunStatus.SUCCEEDED.value, + ) + ), + ) + with self.engine.begin() as connection: + return ( + self._change_comparison( + connection, + report_run_id, + expected=RunStatus.PENDING, + values={"comparison_status": RunStatus.DISPATCHING.value}, + conditions=both_runs_succeeded, + ) + is not None + ) + + def record_comparison_dispatched( + self, + report_run_id: UUID, + *, + modal_call_id: str, + first_check_at: datetime, + ) -> bool: + """Record the comparison's acknowledged Modal call; it is now running.""" + with self.engine.begin() as connection: + return ( + self._change_comparison( + connection, + report_run_id, + expected=RunStatus.DISPATCHING, + values={ + "comparison_status": RunStatus.RUNNING.value, + "comparison_modal_call_id": modal_call_id, + "comparison_dispatched_at": self._clock(), + "comparison_next_check_at": first_check_at, + }, + ) + is not None + ) + + def record_comparison_dispatch_failure( + self, report_run_id: UUID, error: RunError + ) -> bool: + """Fail a dispatching comparison step whose Modal call was refused.""" + with self.engine.begin() as connection: + return ( + self._change_comparison( + connection, + report_run_id, + expected=RunStatus.DISPATCHING, + values=self._comparison_failure(error), + ) + is not None + ) + + def claim_due_comparisons( + self, *, limit: int, lease: timedelta + ) -> list[ReportRunRecord]: + """Claim up to ``limit`` running comparison steps that are due a check.""" + now = self._clock() + due = ( + select(report_runs.c.id) + .where( + report_runs.c.comparison_status == RunStatus.RUNNING.value, + report_runs.c.comparison_next_check_at <= now, + ) + .order_by(report_runs.c.comparison_next_check_at) + .limit(limit) + .with_for_update(skip_locked=True) + .scalar_subquery() + ) + with self.engine.begin() as connection: + rows = ( + connection.execute( + update(report_runs) + .where( + report_runs.c.id.in_(due), + report_runs.c.comparison_status == RunStatus.RUNNING.value, + report_runs.c.comparison_next_check_at <= now, + ) + .values( + comparison_next_check_at=now + lease, + comparison_check_count=report_runs.c.comparison_check_count + 1, + updated_at=now, + ) + .returning(*report_runs.c) + ) + .mappings() + .all() + ) + return [ReportRunRecord.from_row(row) for row in rows] + + def record_comparison_running( + self, report_run_id: UUID, *, next_check_at: datetime + ) -> bool: + """Set the next check of a comparison step that is still running.""" + with self.engine.begin() as connection: + return ( + self._change_comparison( + connection, + report_run_id, + expected=RunStatus.RUNNING, + values={"comparison_next_check_at": next_check_at}, + ) + is not None + ) + + def complete_comparison( + self, report_run_id: UUID, write_results: ComparisonResultWriter + ) -> bool: + """Mark a running comparison step succeeded and write its results. + + ``write_results`` runs in the same transaction; if it raises, nothing + is recorded and the exception propagates. + """ + with self.engine.begin() as connection: + report_run = self._change_comparison( + connection, + report_run_id, + expected=RunStatus.RUNNING, + values={ + "comparison_status": RunStatus.SUCCEEDED.value, + "comparison_completed_at": self._clock(), + "comparison_next_check_at": None, + }, + ) + if report_run is None: + return False + write_results(connection, report_run) + return True + + def fail_comparison(self, report_run_id: UUID, error: RunError) -> bool: + """Mark a running comparison step failed; False if it is not running.""" + with self.engine.begin() as connection: + return ( + self._change_comparison( + connection, + report_run_id, + expected=RunStatus.RUNNING, + values=self._comparison_failure(error), + ) + is not None + ) + + def _comparison_failure(self, error: RunError) -> dict[str, object]: + return { + "comparison_status": RunStatus.FAILED.value, + "comparison_error_code": error.code.value, + "comparison_error_message": bounded_error_message(error.message), + "comparison_completed_at": self._clock(), + "comparison_next_check_at": None, + } + + # --- Dispatches that never finished ------------------------------------ + + def fail_abandoned_dispatches( + self, *, claimed_before: datetime + ) -> AbandonedDispatches: + """Fail runs and comparison steps left in ``dispatching``. + + A run stays ``dispatching`` only if the instance that claimed it + stopped before recording its Modal call. Failing it, with + ``dispatch_abandoned``, lets the same simulation be run again. + """ + now = self._clock() + message = ( + "The instance that claimed the dispatch stopped before it recorded " + "the Modal call." + ) + with self.engine.begin() as connection: + failed_runs = connection.execute( + update(simulation_runs) + .where( + simulation_runs.c.status == RunStatus.DISPATCHING.value, + simulation_runs.c.updated_at < claimed_before, + ) + .values( + status=RunStatus.FAILED.value, + error_code=RunErrorCode.DISPATCH_ABANDONED.value, + error_message=message, + completed_at=now, + updated_at=now, + ) + .returning(simulation_runs.c.id) + ).scalars() + simulation_run_ids = tuple(failed_runs) + report_run_ids = tuple( + connection.execute( + update(report_runs) + .where( + report_runs.c.comparison_status == RunStatus.DISPATCHING.value, + report_runs.c.updated_at < claimed_before, + ) + .values( + updated_at=now, + **self._comparison_failure( + RunError(RunErrorCode.DISPATCH_ABANDONED, message) + ), + ) + .returning(report_runs.c.id) + ).scalars() + ) + return AbandonedDispatches( + simulation_run_ids=simulation_run_ids, report_run_ids=report_run_ids + ) diff --git a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py new file mode 100644 index 000000000..689683849 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py @@ -0,0 +1,491 @@ +"""Conditional status changes: a second writer never changes anything.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier + +import pytest +from policyengine_simulation_persistence import ( + NewReportRun, + ReportKind, + RunError, + RunErrorCode, + RunStatus, + RunTransitions, + RunTrigger, + create_simulation_engine, +) +from report_support import economy_pair, economy_report, household_baseline +from report_support import report_store as definition_report_store +from run_support import economy_run, household_run +from simulation_support import household_simulation, simulation_store +from transition_support import ManualClock, clocked_stores + +pytestmark = pytest.mark.postgres + +LEASE = timedelta(minutes=1) +FIRST_CHECK = timedelta(seconds=2) +WORKER_ERROR = RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised KeyError.") + + +def _pending_run(stores, database, rows): + baseline, _ = economy_pair(database, rows) + return stores.runs.start_simulation_run(economy_run(baseline, rows)).run + + +def _running_run(stores, database, rows, call_id="fc-economy"): + run = _pending_run(stores, database, rows) + assert stores.transitions.claim_simulation_dispatch([run.id]) + assert stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=call_id, first_check_at=stores.clock() + FIRST_CHECK + ) + return stores.runs.get_simulation_run(run.id) + + +def _household_pair(stores, database, rows): + baseline = household_baseline(database, rows) + reform = simulation_store(database).create( + household_simulation(rows, policy_id=rows.us_policy_id) + ) + return ( + stores.runs.start_simulation_run(household_run(baseline, rows)).run, + stores.runs.start_simulation_run(household_run(reform, rows)).run, + ) + + +def _no_results(_connection, _records) -> None: + return None + + +def test_only_one_claim_moves_a_run_to_dispatching( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _pending_run(stores, migrated_database, public_api_rows) + + assert stores.transitions.claim_simulation_dispatch([run.id]) is True + assert stores.transitions.claim_simulation_dispatch([run.id]) is False + + claimed = stores.runs.get_simulation_run(run.id) + assert claimed.status is RunStatus.DISPATCHING + assert claimed.modal_call_id is None + + +def test_concurrent_claims_start_one_modal_call( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _pending_run(stores, migrated_database, public_api_rows) + instances = 8 + barrier = Barrier(instances) + + def claim(_instance: int) -> bool: + transitions = RunTransitions( + create_simulation_engine(migrated_database.runtime_url) + ) + try: + barrier.wait() + return transitions.claim_simulation_dispatch([run.id]) + finally: + transitions.engine.dispose() + + with ThreadPoolExecutor(max_workers=instances) as pool: + outcomes = list(pool.map(claim, range(instances))) + + assert outcomes.count(True) == 1 + + +def test_a_dispatched_run_records_its_call_and_first_check( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _running_run(stores, migrated_database, public_api_rows) + + assert run.status is RunStatus.RUNNING + assert run.modal_call_id == "fc-economy" + assert run.dispatched_at == stores.clock() + assert run.next_check_at == stores.clock() + FIRST_CHECK + assert not stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id="fc-other", first_check_at=stores.clock() + ) + + +def test_a_refused_dispatch_fails_the_run_and_frees_the_setup( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _pending_run(stores, migrated_database, public_api_rows) + stores.transitions.claim_simulation_dispatch([run.id]) + + assert stores.transitions.record_simulation_dispatch_failure( + [run.id], RunError(RunErrorCode.DISPATCH_FAILED, "Modal refused the call.") + ) + + failed = stores.runs.get_simulation_run(run.id) + assert failed.status is RunStatus.FAILED + assert failed.error_code == "dispatch_failed" + assert failed.completed_at == stores.clock() + simulation = simulation_store(migrated_database).get(run.simulation_id) + retry = stores.runs.start_simulation_run(economy_run(simulation, public_api_rows)) + assert retry.created is True + assert retry.run.attempt == 2 + + +def test_a_household_pair_shares_one_call_and_changes_together( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + baseline_run, reform_run = _household_pair( + stores, migrated_database, public_api_rows + ) + pair = [baseline_run.id, reform_run.id] + written = [] + + assert stores.transitions.claim_simulation_dispatch(pair) + assert stores.transitions.record_simulation_dispatched( + pair, modal_call_id="fc-household", first_check_at=stores.clock() + ) + assert stores.transitions.complete_simulation_call( + "fc-household", lambda _connection, runs: written.extend(runs) + ) + + assert {run.id for run in written} == set(pair) + for run_id in pair: + run = stores.runs.get_simulation_run(run_id) + assert run.status is RunStatus.SUCCEEDED + assert run.modal_call_id == "fc-household" + assert run.next_check_at is None + + +def test_a_group_claim_changes_nothing_unless_every_run_is_pending( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + baseline_run, reform_run = _household_pair( + stores, migrated_database, public_api_rows + ) + assert stores.transitions.claim_simulation_dispatch([baseline_run.id]) + + assert not stores.transitions.claim_simulation_dispatch( + [baseline_run.id, reform_run.id] + ) + + assert stores.runs.get_simulation_run(reform_run.id).status is RunStatus.PENDING + with pytest.raises(ValueError, match="at least one run"): + stores.transitions.claim_simulation_dispatch([]) + + +def test_due_calls_are_claimed_for_one_lease_at_a_time( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _running_run(stores, migrated_database, public_api_rows) + transitions = stores.transitions + + assert transitions.claim_due_simulation_calls(limit=10, lease=LEASE) == [] + stores.clock.advance(FIRST_CHECK) + claimed = transitions.claim_due_simulation_calls(limit=10, lease=LEASE) + assert [call.modal_call_id for call in claimed] == ["fc-economy"] + assert claimed[0].runs[0].id == run.id + assert claimed[0].runs[0].check_count == 1 + assert claimed[0].runs[0].next_check_at == stores.clock() + LEASE + assert transitions.claim_due_simulation_calls(limit=10, lease=LEASE) == [] + + # The lease ends when the claiming instance stops before recording anything. + stores.clock.advance(LEASE) + reclaimed = transitions.claim_due_simulation_calls(limit=10, lease=LEASE) + assert reclaimed[0].runs[0].check_count == 2 + + +def test_concurrent_instances_claim_disjoint_calls( + migrated_database, public_api_rows +) -> None: + clock = ManualClock() + stores = clocked_stores(migrated_database, clock) + simulations = simulation_store(migrated_database) + run_ids = set() + for index in range(12): + simulation = simulations.create( + household_simulation(public_api_rows, legacy_simulation_id=index + 1) + ) + run = stores.runs.start_simulation_run( + household_run(simulation, public_api_rows) + ).run + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=f"fc-{index}", first_check_at=clock() + ) + run_ids.add(run.id) + instances = 4 + barrier = Barrier(instances) + + def claim(_instance: int) -> list[str]: + transitions = RunTransitions( + create_simulation_engine(migrated_database.runtime_url), clock=clock + ) + try: + barrier.wait() + return [ + call.modal_call_id + for call in transitions.claim_due_simulation_calls(limit=5, lease=LEASE) + ] + finally: + transitions.engine.dispose() + + with ThreadPoolExecutor(max_workers=instances) as pool: + claims = list(pool.map(claim, range(instances))) + + claimed_calls = [call for instance_claims in claims for call in instance_claims] + assert len(claimed_calls) == len(set(claimed_calls)) == len(run_ids) + + +def test_a_running_call_gets_its_next_check(migrated_database, public_api_rows) -> None: + stores = clocked_stores(migrated_database) + run = _running_run(stores, migrated_database, public_api_rows) + later = stores.clock() + timedelta(seconds=30) + + assert stores.transitions.record_simulation_call_running( + "fc-economy", next_check_at=later + ) + + assert stores.runs.get_simulation_run(run.id).next_check_at == later + assert not stores.transitions.record_simulation_call_running( + "fc-unknown", next_check_at=later + ) + + +def test_results_are_written_in_the_transaction_that_marks_success( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _running_run(stores, migrated_database, public_api_rows) + + def failing_writer(_connection, _runs) -> None: + msg = "the result rows could not be written" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="result rows"): + stores.transitions.complete_simulation_call("fc-economy", failing_writer) + + assert stores.runs.get_simulation_run(run.id).status is RunStatus.RUNNING + assert stores.transitions.complete_simulation_call("fc-economy", _no_results) + succeeded = stores.runs.get_simulation_run(run.id) + assert succeeded.status is RunStatus.SUCCEEDED + assert succeeded.completed_at == stores.clock() + + +def test_a_finished_call_is_recorded_once(migrated_database, public_api_rows) -> None: + stores = clocked_stores(migrated_database) + _running_run(stores, migrated_database, public_api_rows) + calls = [] + + assert stores.transitions.complete_simulation_call("fc-economy", _no_results) + + assert not stores.transitions.complete_simulation_call( + "fc-economy", lambda _connection, runs: calls.append(runs) + ) + assert calls == [] + assert not stores.transitions.fail_simulation_call("fc-economy", WORKER_ERROR) + + +def test_a_failed_call_records_a_bounded_error( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = _running_run(stores, migrated_database, public_api_rows) + + assert stores.transitions.fail_simulation_call( + "fc-economy", RunError(RunErrorCode.WORKER_ERROR, "x" * 5000) + ) + + failed = stores.runs.get_simulation_run(run.id) + assert failed.status is RunStatus.FAILED + assert failed.error_code == "worker_error" + assert len(failed.error_message) == 2000 + assert failed.next_check_at is None + + +def _economy_report_run(stores, database, rows): + baseline, reform = economy_pair(database, rows) + report = definition_report_store(database).create(economy_report(baseline, reform)) + runs = [ + stores.runs.start_simulation_run(economy_run(simulation, rows)).run + for simulation in (baseline, reform) + ] + report_run = stores.runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=runs[0].id, + reform_simulation_run_id=runs[1].id, + trigger=RunTrigger.INITIAL, + ) + ) + return report_run, runs + + +def _succeed(stores, run, call_id) -> None: + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=call_id, first_check_at=stores.clock() + ) + assert stores.transitions.complete_simulation_call(call_id, _no_results) + + +def test_a_comparison_starts_only_after_both_runs_succeed( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + + assert not stores.transitions.claim_comparison_dispatch(report_run.id) + _succeed(stores, baseline_run, "fc-baseline") + assert not stores.transitions.claim_comparison_dispatch(report_run.id) + _succeed(stores, reform_run, "fc-reform") + + assert stores.transitions.claim_comparison_dispatch(report_run.id) + assert not stores.transitions.claim_comparison_dispatch(report_run.id) + assert ( + stores.runs.get_report_run(report_run.id).comparison_status + is RunStatus.DISPATCHING + ) + + +def test_a_comparison_runs_and_records_its_results_once( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + _succeed(stores, baseline_run, "fc-baseline") + _succeed(stores, reform_run, "fc-reform") + transitions = stores.transitions + transitions.claim_comparison_dispatch(report_run.id) + written = [] + + assert transitions.record_comparison_dispatched( + report_run.id, modal_call_id="fc-comparison", first_check_at=stores.clock() + ) + claimed = transitions.claim_due_comparisons(limit=10, lease=LEASE) + assert [due.id for due in claimed] == [report_run.id] + assert claimed[0].comparison_check_count == 1 + assert transitions.claim_due_comparisons(limit=10, lease=LEASE) == [] + assert transitions.record_comparison_running( + report_run.id, next_check_at=stores.clock() + LEASE + ) + assert transitions.complete_comparison( + report_run.id, lambda _connection, record: written.append(record) + ) + + assert [record.id for record in written] == [report_run.id] + finished = stores.runs.get_report_run(report_run.id) + assert finished.comparison_status is RunStatus.SUCCEEDED + assert finished.comparison_modal_call_id == "fc-comparison" + assert finished.comparison_next_check_at is None + assert not transitions.complete_comparison(report_run.id, _no_results) + assert not transitions.fail_comparison(report_run.id, WORKER_ERROR) + + +def test_a_comparison_failure_is_recorded(migrated_database, public_api_rows) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + _succeed(stores, baseline_run, "fc-baseline") + _succeed(stores, reform_run, "fc-reform") + transitions = stores.transitions + transitions.claim_comparison_dispatch(report_run.id) + transitions.record_comparison_dispatched( + report_run.id, modal_call_id="fc-comparison", first_check_at=stores.clock() + ) + + assert transitions.fail_comparison(report_run.id, WORKER_ERROR) + + failed = stores.runs.get_report_run(report_run.id) + assert failed.comparison_status is RunStatus.FAILED + assert failed.comparison_error_code == "worker_error" + assert failed.comparison_completed_at == stores.clock() + + +def test_a_refused_comparison_dispatch_fails_the_step( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + _succeed(stores, baseline_run, "fc-baseline") + _succeed(stores, reform_run, "fc-reform") + stores.transitions.claim_comparison_dispatch(report_run.id) + + assert stores.transitions.record_comparison_dispatch_failure( + report_run.id, RunError(RunErrorCode.DISPATCH_FAILED, "Modal refused it.") + ) + + failed = stores.runs.get_report_run(report_run.id) + assert failed.comparison_status is RunStatus.FAILED + assert failed.comparison_error_code == "dispatch_failed" + assert failed.comparison_modal_call_id is None + + +def test_household_report_runs_have_no_comparison_to_claim( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + baseline_run, reform_run = _household_pair( + stores, migrated_database, public_api_rows + ) + baseline = simulation_store(migrated_database).get(baseline_run.simulation_id) + reform = simulation_store(migrated_database).get(reform_run.simulation_id) + report = definition_report_store(migrated_database).create( + economy_report(baseline, reform, kind=ReportKind.HOUSEHOLD) + ) + report_run = stores.runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + assert not stores.transitions.claim_comparison_dispatch(report_run.id) + + +def test_dispatches_left_for_ten_minutes_are_failed( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + _succeed(stores, baseline_run, "fc-baseline") + _succeed(stores, reform_run, "fc-reform") + stores.transitions.claim_comparison_dispatch(report_run.id) + stalled = _pending_run(stores, migrated_database, public_api_rows) + stores.transitions.claim_simulation_dispatch([stalled.id]) + stores.clock.advance(timedelta(minutes=10, seconds=1)) + recent = household_run( + household_baseline(migrated_database, public_api_rows), public_api_rows + ) + recent_run = stores.runs.start_simulation_run(recent).run + stores.transitions.claim_simulation_dispatch([recent_run.id]) + + abandoned = stores.transitions.fail_abandoned_dispatches( + claimed_before=stores.clock() - timedelta(minutes=10) + ) + + assert abandoned.simulation_run_ids == (stalled.id,) + assert abandoned.report_run_ids == (report_run.id,) + failed_run = stores.runs.get_simulation_run(stalled.id) + assert failed_run.status is RunStatus.FAILED + assert failed_run.error_code == "dispatch_abandoned" + assert ( + stores.runs.get_report_run(report_run.id).comparison_error_code + == "dispatch_abandoned" + ) + assert stores.runs.get_simulation_run(recent_run.id).status is RunStatus.DISPATCHING diff --git a/libs/policyengine-simulation-persistence/tests/transition_support.py b/libs/policyengine-simulation-persistence/tests/transition_support.py new file mode 100644 index 000000000..426007b8f --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/transition_support.py @@ -0,0 +1,49 @@ +"""A clock the tests move by hand, and stores that share it.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta + +from policyengine_simulation_persistence import ( + RunStore, + RunTransitions, + create_simulation_engine, +) + + +@dataclass +class ManualClock: + """Return a fixed time until the test advances it.""" + + now: datetime = field( + default_factory=lambda: datetime(2026, 10, 22, 12, 0, tzinfo=UTC) + ) + + def __call__(self) -> datetime: + """Return the current fake time.""" + return self.now + + def advance(self, interval: timedelta) -> None: + """Move the fake time forward by ``interval``.""" + self.now += interval + + +@dataclass(frozen=True) +class ClockedStores: + """A run store and transitions on one runtime engine and one clock.""" + + runs: RunStore + transitions: RunTransitions + clock: ManualClock + + +def clocked_stores(database, clock: ManualClock | None = None) -> ClockedStores: + """Return stores that connect as the runtime role and share ``clock``.""" + clock = clock or ManualClock() + engine = create_simulation_engine(database.runtime_url) + return ClockedStores( + runs=RunStore(engine, clock=clock), + transitions=RunTransitions(engine, clock=clock), + clock=clock, + ) From 06204f5af4f5410665bca18aacac9872eb10ea40 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:31:52 +0400 Subject: [PATCH 046/124] Start each run's Modal call exactly once from the Simulation Entrypoint RunDispatchService starts the Modal call of simulation runs, or of an economy report run's comparison step, with three conditional writes around Modal's spawn: claim the runs (pending to dispatching), spawn the call and wait only for Modal's acknowledgement, then record the call ID and the first check two seconds later (running). Only the caller whose claim changed the runs spawns; every other caller gets NOT_CLAIMED. A household report's baseline and reform runs are started together under one call. A call Modal refuses fails the runs with dispatch_failed and the error's type only, never its message. A comparison can be claimed only once both of its simulation runs have succeeded. modal_calls holds the main path's own Modal adapters, separate from the Stage 12 comparison path's: ModalCallDispatcher spawns a deployed function with the observability context as its separate keyword argument, and ModalCallInspector asks about a call with get(timeout=0), which returns at once. No output yet (including a call Modal is re-running after pre-emption) reads as running; a timed-out, expired, unknown or raising call reads as failed with its error type only; a problem reaching Modal raises CallInspectionError so the call is asked about again later. No route starts calls yet. Co-Authored-By: Claude Opus 5.5 --- .../modal_calls.py | 154 ++++++++++++ .../run_dispatch.py | 198 +++++++++++++++ .../tests/run_fakes.py | 221 +++++++++++++++++ .../tests/test_run_dispatch.py | 233 ++++++++++++++++++ 4 files changed, 806 insertions(+) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py create mode 100644 projects/policyengine-simulation-entry/tests/run_fakes.py create mode 100644 projects/policyengine-simulation-entry/tests/test_run_dispatch.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py new file mode 100644 index 000000000..92f79f0d0 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py @@ -0,0 +1,154 @@ +"""Start Modal calls and ask about them without waiting, for simulation runs. + +The Simulation Entrypoint starts each run's Modal call with ``spawn`` and later +asks Modal about it with ``get(timeout=0)``, which returns at once. A call +Modal pre-empted is re-run on the same input under the same call ID, so it +reads as still running and finishes later: pre-emption is never a failure. + +These adapters belong to the main path. The Stage 12 comparison path keeps its +own Modal code; the two share nothing. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +import modal +from policyengine_simulation_persistence import RunError, RunErrorCode + + +@dataclass(frozen=True) +class ModalTarget: + """A deployed Modal function: its application and function names.""" + + application: str + function: str + + +@dataclass(frozen=True) +class ModalRequest: + """One call to start: the target, its positional arguments and telemetry. + + ``arguments`` hold calculation inputs and functional metadata only. + ``observability_context`` travels as Modal's separate keyword argument. + """ + + target: ModalTarget + arguments: tuple[object, ...] + observability_context: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class CallRunning: + """The call has produced no output yet, or Modal is re-running it.""" + + +@dataclass(frozen=True) +class CallSucceeded: + """The call returned ``output``.""" + + output: object + + +@dataclass(frozen=True) +class CallFailed: + """The call failed; the error carries no calculation data.""" + + error: RunError + + +type CallOutcome = CallRunning | CallSucceeded | CallFailed + + +class CallInspectionError(RuntimeError): + """Modal could not be asked about a call; ask again later.""" + + +class CallDispatcher(Protocol): + """Start a Modal call without waiting for it.""" + + async def spawn(self, request: ModalRequest) -> str: + """Return the call ID once Modal acknowledges the call.""" + ... + + +class CallInspector(Protocol): + """Ask Modal about a call without waiting for it.""" + + async def inspect(self, modal_call_id: str) -> CallOutcome: + """Return the call's outcome; raise CallInspectionError if unknown.""" + ... + + +class ModalCallDispatcher: + """Spawn deployed Modal functions of one Modal environment.""" + + def __init__(self, environment: str) -> None: + """Look functions up in ``environment``.""" + self._environment = environment + + async def spawn(self, request: ModalRequest) -> str: + """Spawn the request's function and return Modal's call ID. + + Raise RuntimeError when Modal acknowledges the call without an ID. + """ + function = modal.Function.from_name( + request.target.application, + request.target.function, + environment_name=self._environment, + ) + call = await function.spawn.aio( + *request.arguments, + observability_context=request.observability_context, + ) + modal_call_id = getattr(call, "object_id", None) + if not isinstance(modal_call_id, str) or not modal_call_id: + msg = "Modal acknowledged the call without a call ID" + raise RuntimeError(msg) + return modal_call_id + + +# Problems reaching Modal: the call's state is unknown, so ask again later. +TRANSIENT_MODAL_ERRORS: tuple[type[Exception], ...] = ( + modal.exception.ConnectionError, + modal.exception.AuthError, + modal.exception.ServiceError, + modal.exception.ResourceExhaustedError, + modal.exception.InternalError, +) + + +def _failed(code: RunErrorCode, message: str) -> CallFailed: + return CallFailed(RunError(code, message)) + + +class ModalCallInspector: + """Read a Modal call's output if it has one, without waiting.""" + + async def inspect(self, modal_call_id: str) -> CallOutcome: + """Return the call's outcome, or raise CallInspectionError.""" + try: + call = modal.FunctionCall.from_id(modal_call_id) + output = await call.get.aio(timeout=0) + # Modal raises the built-in TimeoutError while the call has no output. + except TimeoutError: + return CallRunning() + except modal.exception.OutputExpiredError: + return _failed( + RunErrorCode.RESULT_EXPIRED, "Modal no longer holds the call's output." + ) + except modal.exception.FunctionTimeoutError: + return _failed(RunErrorCode.WORKER_TIMEOUT, "The Modal call timed out.") + except modal.exception.NotFoundError: + return _failed(RunErrorCode.CALL_NOT_FOUND, "Modal does not know the call.") + except TRANSIENT_MODAL_ERRORS as error: + raise CallInspectionError(type(error).__name__) from error + # The call re-raises whatever the worker raised. Record only its type: + # the message may contain calculation inputs. + except Exception as error: # noqa: BLE001 # every worker failure fails the run + return _failed( + RunErrorCode.WORKER_ERROR, + f"The Modal call raised {type(error).__name__}.", + ) + return CallSucceeded(output) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py new file mode 100644 index 000000000..76eb398bb --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -0,0 +1,198 @@ +"""Start each simulation run's, or comparison step's, Modal call exactly once. + +Starting a call takes three conditional writes around Modal's ``spawn``: + +1. claim: move the runs from ``pending`` to ``dispatching``. Only the instance + whose claim changes them goes on; every other caller stops here. +2. spawn the Modal call and wait only for Modal's acknowledgement. +3. record the call ID, moving the runs to ``running`` with their first check + due shortly; or, when Modal refused the call, fail them. + +If the instance stops between 1 and 3, the runs stay ``dispatching`` until the +recording loop fails them as abandoned. No route starts calls yet. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import timedelta +from enum import StrEnum +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_persistence import RunError, RunErrorCode +from policyengine_simulation_persistence.run_store import utc_now + +if TYPE_CHECKING: + from collections.abc import Callable, Collection + from datetime import datetime + from uuid import UUID + + from policyengine_simulation_entry.modal_calls import CallDispatcher, ModalRequest + +logger = logging.getLogger(__name__) + +# The first check of a new call; the recording loop backs off from there. +FIRST_CHECK_DELAY = timedelta(seconds=2) + + +class DispatchOutcome(StrEnum): + """What one attempt to start a call did.""" + + # This caller claimed the runs and Modal acknowledged the call. + STARTED = "started" + # Another caller claimed the runs first, or they were no longer pending. + NOT_CLAIMED = "not_claimed" + # Modal refused the call; the runs are failed. + DISPATCH_FAILED = "dispatch_failed" + # Modal acknowledged the call, but the runs had stopped dispatching before + # the call ID could be recorded. + NOT_RECORDED = "not_recorded" + + +class DispatchTransitions(Protocol): + """The ``RunTransitions`` writes that starting a call needs.""" + + def claim_simulation_dispatch(self, run_ids: Collection[UUID]) -> bool: + """Move pending runs to dispatching.""" + ... + + def record_simulation_dispatched( + self, + run_ids: Collection[UUID], + *, + modal_call_id: str, + first_check_at: datetime, + ) -> bool: + """Record the call and move the runs to running.""" + ... + + def record_simulation_dispatch_failure( + self, run_ids: Collection[UUID], error: RunError + ) -> bool: + """Fail dispatching runs.""" + ... + + def claim_comparison_dispatch(self, report_run_id: UUID) -> bool: + """Move a pending comparison step to dispatching.""" + ... + + def record_comparison_dispatched( + self, + report_run_id: UUID, + *, + modal_call_id: str, + first_check_at: datetime, + ) -> bool: + """Record the comparison's call and move it to running.""" + ... + + def record_comparison_dispatch_failure( + self, report_run_id: UUID, error: RunError + ) -> bool: + """Fail a dispatching comparison step.""" + ... + + +@dataclass(frozen=True) +class _DispatchSteps: + """The three writes around one spawn, bound to the runs they change.""" + + claim: Callable[[], bool] + record: Callable[[str, datetime], bool] + fail: Callable[[RunError], bool] + + +@dataclass +class RunDispatchService: + """Start Modal calls for simulation runs and comparison steps.""" + + transitions: DispatchTransitions + dispatcher: CallDispatcher + clock: Callable[[], datetime] = utc_now + + async def start_simulation_runs( + self, run_ids: Collection[UUID], request: ModalRequest + ) -> DispatchOutcome: + """Start the one Modal call that computes ``run_ids``. + + Pass a household report's baseline and reform runs together: one call + computes both, and they change status together. + """ + runs = frozenset(run_ids) + return await self._start( + _DispatchSteps( + claim=lambda: self.transitions.claim_simulation_dispatch(runs), + record=lambda call_id, first_check_at: ( + self.transitions.record_simulation_dispatched( + runs, modal_call_id=call_id, first_check_at=first_check_at + ) + ), + fail=lambda error: self.transitions.record_simulation_dispatch_failure( + runs, error + ), + ), + request, + subject={"simulation_run_ids": sorted(str(run_id) for run_id in runs)}, + ) + + async def start_comparison( + self, report_run_id: UUID, request: ModalRequest + ) -> DispatchOutcome: + """Start an economy report run's comparison step. + + The claim succeeds only once both of its simulation runs have + succeeded. + """ + return await self._start( + _DispatchSteps( + claim=lambda: self.transitions.claim_comparison_dispatch(report_run_id), + record=lambda call_id, first_check_at: ( + self.transitions.record_comparison_dispatched( + report_run_id, + modal_call_id=call_id, + first_check_at=first_check_at, + ) + ), + fail=lambda error: self.transitions.record_comparison_dispatch_failure( + report_run_id, error + ), + ), + request, + subject={"report_run_id": str(report_run_id)}, + ) + + async def _start( + self, + steps: _DispatchSteps, + request: ModalRequest, + *, + subject: dict[str, object], + ) -> DispatchOutcome: + if not await asyncio.to_thread(steps.claim): + return DispatchOutcome.NOT_CLAIMED + try: + modal_call_id = await self.dispatcher.spawn(request) + # Any failure to start the call fails the runs; they can be run again. + except Exception as error: # noqa: BLE001 # recorded on the runs + logger.warning( + "simulation_run_dispatch_failed", + extra={**subject, "error_type": type(error).__name__}, + ) + await asyncio.to_thread( + steps.fail, + RunError( + RunErrorCode.DISPATCH_FAILED, + f"Modal did not accept the call ({type(error).__name__}).", + ), + ) + return DispatchOutcome.DISPATCH_FAILED + recorded = await asyncio.to_thread( + steps.record, modal_call_id, self.clock() + FIRST_CHECK_DELAY + ) + logger.info( + "simulation_run_dispatched", + extra={**subject, "modal_call_id": modal_call_id, "recorded": recorded}, + ) + return DispatchOutcome.STARTED if recorded else DispatchOutcome.NOT_RECORDED diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py new file mode 100644 index 000000000..1af6983b4 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -0,0 +1,221 @@ +"""In-memory stand-ins for the run transitions and Modal, for the run tests. + +``FakeRunTransitions`` keeps each simulation run's and comparison step's status +and applies the same conditional rules as ``RunTransitions``: a change applies +only from the expected status, and the runs of one call change together. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime, timedelta +from threading import Lock +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +from policyengine_simulation_entry.modal_calls import ModalRequest, ModalTarget +from policyengine_simulation_persistence import RunError, RunStatus + +if TYPE_CHECKING: + from collections.abc import Collection + + +@dataclass +class ManualClock: + """Return a fixed time until a test advances it.""" + + now: datetime = field( + default_factory=lambda: datetime(2026, 10, 22, 12, 0, tzinfo=UTC) + ) + + def __call__(self) -> datetime: + """Return the current fake time.""" + return self.now + + def advance(self, interval: timedelta) -> None: + """Move the fake time forward.""" + self.now += interval + + +@dataclass +class FakeExecution: + """One simulation run, or one comparison step, as the fake keeps it.""" + + id: UUID + kind: str + status: RunStatus = RunStatus.PENDING + modal_call_id: str | None = None + next_check_at: datetime | None = None + check_count: int = 0 + error: RunError | None = None + updated_at: datetime | None = None + written_results: list[Any] = field(default_factory=list) + + +class FakeRunTransitions: + """Conditional status changes over in-memory runs and comparison steps.""" + + def __init__(self, clock: ManualClock) -> None: + """Start with no runs.""" + self.clock = clock + # Callers run these methods in worker threads, as with the database. + self.lock = Lock() + self.runs: dict[UUID, FakeExecution] = {} + self.comparisons: dict[UUID, FakeExecution] = {} + self.comparison_runs_succeeded: dict[UUID, bool] = {} + + # --- Building state -------------------------------------------------- + + def add_run(self, kind: str = "economy", **values: Any) -> UUID: + """Add a simulation run and return its ID.""" + run = FakeExecution(id=uuid4(), kind=kind, **values) + self.runs[run.id] = run + return run.id + + def add_comparison(self, *, runs_succeeded: bool = True, **values: Any) -> UUID: + """Add an economy report run's comparison step and return its ID.""" + comparison = FakeExecution(id=uuid4(), kind="economy", **values) + self.comparisons[comparison.id] = comparison + self.comparison_runs_succeeded[comparison.id] = runs_succeeded + return comparison.id + + def running_call(self, call_id: str, *kinds: str) -> list[UUID]: + """Add runs already running under ``call_id``, due a check now.""" + return [ + self.add_run( + kind, + status=RunStatus.RUNNING, + modal_call_id=call_id, + next_check_at=self.clock(), + ) + for kind in kinds or ("economy",) + ] + + # --- Dispatch ---------------------------------------------------------- + + def _change_runs( + self, run_ids: Collection[UUID], expected: RunStatus, **values: Any + ) -> bool: + with self.lock: + runs = [self.runs[run_id] for run_id in run_ids] + if not runs or any(run.status is not expected for run in runs): + return False + for run in runs: + for name, value in values.items(): + setattr(run, name, value) + run.updated_at = self.clock() + return True + + def claim_simulation_dispatch(self, run_ids: Collection[UUID]) -> bool: + """Move pending runs to dispatching.""" + return self._change_runs( + run_ids, RunStatus.PENDING, status=RunStatus.DISPATCHING + ) + + def record_simulation_dispatched( + self, + run_ids: Collection[UUID], + *, + modal_call_id: str, + first_check_at: datetime, + ) -> bool: + """Record the call and move the runs to running.""" + return self._change_runs( + run_ids, + RunStatus.DISPATCHING, + status=RunStatus.RUNNING, + modal_call_id=modal_call_id, + next_check_at=first_check_at, + ) + + def record_simulation_dispatch_failure( + self, run_ids: Collection[UUID], error: RunError + ) -> bool: + """Fail dispatching runs.""" + return self._change_runs( + run_ids, RunStatus.DISPATCHING, status=RunStatus.FAILED, error=error + ) + + def _change_comparison( + self, report_run_id: UUID, expected: RunStatus, **values: Any + ) -> FakeExecution | None: + with self.lock: + comparison = self.comparisons[report_run_id] + if comparison.status is not expected: + return None + for name, value in values.items(): + setattr(comparison, name, value) + comparison.updated_at = self.clock() + return comparison + + def claim_comparison_dispatch(self, report_run_id: UUID) -> bool: + """Move a pending comparison step whose runs succeeded to dispatching.""" + if not self.comparison_runs_succeeded[report_run_id]: + return False + return ( + self._change_comparison( + report_run_id, RunStatus.PENDING, status=RunStatus.DISPATCHING + ) + is not None + ) + + def record_comparison_dispatched( + self, + report_run_id: UUID, + *, + modal_call_id: str, + first_check_at: datetime, + ) -> bool: + """Record the comparison's call.""" + return ( + self._change_comparison( + report_run_id, + RunStatus.DISPATCHING, + status=RunStatus.RUNNING, + modal_call_id=modal_call_id, + next_check_at=first_check_at, + ) + is not None + ) + + def record_comparison_dispatch_failure( + self, report_run_id: UUID, error: RunError + ) -> bool: + """Fail a dispatching comparison step.""" + return ( + self._change_comparison( + report_run_id, + RunStatus.DISPATCHING, + status=RunStatus.FAILED, + error=error, + ) + is not None + ) + + +class FakeDispatcher: + """Record spawned requests and return call IDs, or fail like Modal.""" + + def __init__(self, failure: Exception | None = None) -> None: + """Fail every spawn with ``failure`` when it is set.""" + self.failure = failure + self.requests: list[ModalRequest] = [] + + async def spawn(self, request: ModalRequest) -> str: + """Return a new call ID, or raise the configured failure.""" + self.requests.append(request) + if self.failure is not None: + raise self.failure + return f"fc-{len(self.requests)}" + + +SAMPLE_REQUEST = ModalRequest( + target=ModalTarget(application="policyengine-simulation-v2", function="run"), + arguments=({"country": "us"},), + observability_context={"traceparent": "00-test"}, +) + + +def execution(transitions: FakeRunTransitions, run_id: UUID) -> FakeExecution: + """Return a copy of a run as the fake keeps it.""" + return replace(transitions.runs[run_id]) diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py new file mode 100644 index 000000000..f4378c4a6 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -0,0 +1,233 @@ +"""Starting a run's Modal call exactly once, and reading Modal's answers.""" + +from __future__ import annotations + +import asyncio + +import modal +import pytest +from policyengine_simulation_entry import modal_calls +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallInspectionError, + CallRunning, + CallSucceeded, + ModalCallDispatcher, + ModalCallInspector, +) +from policyengine_simulation_entry.run_dispatch import ( + FIRST_CHECK_DELAY, + DispatchOutcome, + RunDispatchService, +) +from policyengine_simulation_persistence import RunErrorCode, RunStatus +from run_fakes import ( + SAMPLE_REQUEST, + FakeDispatcher, + FakeRunTransitions, + ManualClock, + execution, +) + + +def _service(dispatcher=None): + clock = ManualClock() + transitions = FakeRunTransitions(clock) + service = RunDispatchService( + transitions=transitions, + dispatcher=dispatcher or FakeDispatcher(), + clock=clock, + ) + return service, transitions, clock + + +@pytest.mark.asyncio +async def test_a_claimed_run_starts_its_call_and_records_it() -> None: + service, transitions, clock = _service() + run_id = transitions.add_run() + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.STARTED + run = execution(transitions, run_id) + assert run.status is RunStatus.RUNNING + assert run.modal_call_id == "fc-1" + assert run.next_check_at == clock() + FIRST_CHECK_DELAY + assert service.dispatcher.requests == [SAMPLE_REQUEST] + + +@pytest.mark.asyncio +async def test_concurrent_starts_spawn_one_call() -> None: + service, transitions, _ = _service() + run_id = transitions.add_run() + + outcomes = await asyncio.gather( + *(service.start_simulation_runs([run_id], SAMPLE_REQUEST) for _ in range(5)) + ) + + assert outcomes.count(DispatchOutcome.STARTED) == 1 + assert outcomes.count(DispatchOutcome.NOT_CLAIMED) == 4 + assert len(service.dispatcher.requests) == 1 + + +@pytest.mark.asyncio +async def test_a_household_pair_starts_one_shared_call() -> None: + service, transitions, _ = _service() + pair = [transitions.add_run("household"), transitions.add_run("household")] + + outcome = await service.start_simulation_runs(pair, SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.STARTED + assert {execution(transitions, run_id).modal_call_id for run_id in pair} == {"fc-1"} + + +@pytest.mark.asyncio +async def test_a_refused_call_fails_the_runs_without_raising() -> None: + service, transitions, _ = _service( + FakeDispatcher(failure=modal.exception.NotFoundError("no such function")) + ) + run_id = transitions.add_run() + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.DISPATCH_FAILED + run = execution(transitions, run_id) + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.DISPATCH_FAILED + assert "NotFoundError" in run.error.message + assert "no such function" not in run.error.message + + +@pytest.mark.asyncio +async def test_a_call_whose_runs_stopped_dispatching_is_reported() -> None: + service, transitions, _ = _service() + run_id = transitions.add_run() + + class SlowDispatcher(FakeDispatcher): + async def spawn(self, request): + # The recording loop gave up on the dispatch in the meantime. + transitions.runs[run_id].status = RunStatus.FAILED + return await super().spawn(request) + + service.dispatcher = SlowDispatcher() + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.NOT_RECORDED + assert execution(transitions, run_id).modal_call_id is None + + +@pytest.mark.asyncio +async def test_a_comparison_starts_only_once_its_runs_succeeded() -> None: + service, transitions, _ = _service() + waiting = transitions.add_comparison(runs_succeeded=False) + ready = transitions.add_comparison() + + assert ( + await service.start_comparison(waiting, SAMPLE_REQUEST) + is DispatchOutcome.NOT_CLAIMED + ) + assert ( + await service.start_comparison(ready, SAMPLE_REQUEST) is DispatchOutcome.STARTED + ) + assert transitions.comparisons[ready].modal_call_id == "fc-1" + assert len(service.dispatcher.requests) == 1 + + +@pytest.mark.asyncio +async def test_a_refused_comparison_call_fails_the_step() -> None: + service, transitions, _ = _service(FakeDispatcher(failure=RuntimeError("down"))) + comparison = transitions.add_comparison() + + outcome = await service.start_comparison(comparison, SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.DISPATCH_FAILED + assert transitions.comparisons[comparison].status is RunStatus.FAILED + + +class _FakeCall: + def __init__(self, behaviour): + self.behaviour = behaviour + self.get = self + + async def aio(self, *, timeout): + assert timeout == 0 + if isinstance(self.behaviour, BaseException): + raise self.behaviour + return self.behaviour + + +def _inspect(monkeypatch, behaviour): + monkeypatch.setattr( + modal_calls.modal.FunctionCall, + "from_id", + lambda call_id: _FakeCall(behaviour), + ) + return asyncio.run(ModalCallInspector().inspect("fc-test")) + + +def test_a_call_without_output_is_running(monkeypatch) -> None: + assert _inspect(monkeypatch, TimeoutError()) == CallRunning() + + +def test_a_finished_call_returns_its_output(monkeypatch) -> None: + assert _inspect(monkeypatch, {"budget": 1.0}) == CallSucceeded({"budget": 1.0}) + + +@pytest.mark.parametrize( + ("raised", "code"), + [ + (modal.exception.FunctionTimeoutError("slow"), RunErrorCode.WORKER_TIMEOUT), + (modal.exception.OutputExpiredError(), RunErrorCode.RESULT_EXPIRED), + (modal.exception.NotFoundError("gone"), RunErrorCode.CALL_NOT_FOUND), + (KeyError("household secret"), RunErrorCode.WORKER_ERROR), + (modal.exception.InternalFailure("lost"), RunErrorCode.WORKER_ERROR), + ], + ids=["timeout", "expired", "not-found", "worker-error", "internal-failure"], +) +def test_a_failed_call_records_only_the_failure_type(monkeypatch, raised, code) -> None: + outcome = _inspect(monkeypatch, raised) + + assert isinstance(outcome, CallFailed) + assert outcome.error.code is code + assert "secret" not in outcome.error.message + + +@pytest.mark.parametrize( + "raised", + [ + modal.exception.ConnectionError("unreachable"), + modal.exception.ServiceError("unavailable"), + ], + ids=["connection", "service"], +) +def test_a_modal_problem_is_retried_later(monkeypatch, raised) -> None: + with pytest.raises(CallInspectionError): + _inspect(monkeypatch, raised) + + +def test_the_dispatcher_passes_observability_as_its_own_argument(monkeypatch) -> None: + spawned = {} + + class _FakeSpawn: + async def aio(self, *arguments, observability_context): + spawned.update(arguments=arguments, context=observability_context) + return type("Call", (), {"object_id": "fc-spawned"})() + + class _FakeFunction: + spawn = _FakeSpawn() + + def from_name(application, function, *, environment_name): + spawned.update(target=(application, function, environment_name)) + return _FakeFunction() + + monkeypatch.setattr(modal_calls.modal.Function, "from_name", from_name) + + call_id = asyncio.run(ModalCallDispatcher("staging").spawn(SAMPLE_REQUEST)) + + assert call_id == "fc-spawned" + assert spawned == { + "target": ("policyengine-simulation-v2", "run", "staging"), + "arguments": ({"country": "us"},), + "context": {"traceparent": "00-test"}, + } From 6002eba452300f9d61e86e49ce4197353dd4af77 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:37:37 +0400 Subject: [PATCH 047/124] Record Modal call outcomes on runs in a background loop in each instance Each Simulation Entrypoint instance with a configured database now runs RunRecordingLoop for its lifetime (started and stopped in the app's lifespan). On each pass, RunRecorder: 1. fails runs and comparison steps left dispatching for ten minutes (dispatch_abandoned), so they can be submitted again; 2. claims up to 50 due simulation calls and 50 due comparison steps, moving each next check one minute ahead, so instances do not check the same call at once and a call is checked again if this instance stops; 3. asks Modal about each claimed call without waiting, ten at a time, and records what it found: - still running (including a call Modal is re-running after pre-emption): the next check, backing off from 2 to 60 seconds; - failed or timed out: the runs fail with the error code and type; - finished: the output is validated by the result recorder registered for the kind of run, then the recorder writes the result rows in the transaction that marks the runs succeeded. A kind with no recorder, or an invalid output, fails the runs (no_result_recorder, invalid_result) without repeating the output. A household report's two runs share one call and are written once, together. If Modal cannot be reached, or writing the results fails, nothing is recorded and the call is checked again after its claim ends. The loop waits five seconds between passes, one second while passes find work, and logs a failed pass without stopping. Every write is conditional on the expected status, so an outcome another instance recorded first is left alone. No kind of run has a result recorder yet (item 1.07 adds them) and no route creates runs, so the loop finds nothing to do until stage 2. Co-Authored-By: Claude Opus 5.5 --- .../src/policyengine_simulation_entry/app.py | 9 + .../entry_runtime.py | 5 + .../run_recording.py | 469 ++++++++++++++++++ .../simulation_database.py | 22 + .../tests/run_fakes.py | 229 ++++++++- .../tests/test_run_recording.py | 366 ++++++++++++++ .../tests/test_simulation_database.py | 3 + 7 files changed, 1101 insertions(+), 2 deletions(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py create mode 100644 projects/policyengine-simulation-entry/tests/test_run_recording.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index d862ebcd1..9ef858f5c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -572,9 +572,18 @@ def create_app( async def lifespan(_: FastAPI) -> AsyncIterator[None]: runtime_settings.validate() await runtime_backend.start() + # Each instance records the outcomes of due Modal calls in the + # background; Cloud Run keeps its CPU allocated between requests. + recording_loop = ( + None if runtime_database is None else runtime_database.recording_loop() + ) + if recording_loop is not None: + recording_loop.start() try: yield finally: + if recording_loop is not None: + await recording_loop.stop() await runtime_backend.close() if runtime_database is not None: runtime_database.dispose() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py index 4898f758f..1353a6cfc 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py @@ -41,6 +41,7 @@ ) from policyengine_simulation_entry.config import Settings + from policyengine_simulation_entry.run_recording import RunRecordingLoop # Moved out of app.py; this code still logs under that module's name. logger = logging.getLogger("policyengine_simulation_entry.app") @@ -220,6 +221,10 @@ async def read_revision(self) -> str | None: """Return the applied migration revision, or None before the first one.""" ... + def recording_loop(self) -> RunRecordingLoop | None: + """Return the loop that records Modal call outcomes on runs, if any.""" + ... + def dispose(self) -> None: """Close every pooled connection.""" ... diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py new file mode 100644 index 000000000..126392609 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -0,0 +1,469 @@ +"""The background loop that records each Modal call's outcome on its runs. + +Each Simulation Entrypoint instance runs one loop. On every pass it: + +1. fails runs and comparison steps left ``dispatching`` for ten minutes, so + they can be submitted again; +2. claims the running calls that are due a check, by moving their next check + one lease ahead, so instances do not check the same call at once; +3. asks Modal about each claimed call without waiting, and records what it + found with conditional writes: a finished call's result is validated and + its result rows are written in the transaction that marks its runs + ``succeeded``; a failed or timed-out call is marked ``failed`` with its + error; a call still running gets a later check, backing off. + +Pre-emption is never a failure: Modal re-runs a pre-empted call under the +same call ID, so it reads as still running. No caller polls, and Modal holds +no database credentials: this loop writes every outcome. Cloud Run keeps the +instance's CPU allocated between requests, so the loop runs then too. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from dataclasses import dataclass, field +from datetime import timedelta +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_persistence import RunError, RunErrorCode +from policyengine_simulation_persistence.run_store import utc_now + +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallInspectionError, + CallRunning, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Mapping + from datetime import datetime + from uuid import UUID + + from policyengine_simulation_persistence import ( + AbandonedDispatches, + DueSimulationCall, + ReportKind, + ReportRunRecord, + SimulationKind, + SimulationRunRecord, + ) + from sqlalchemy import Connection + + from policyengine_simulation_entry.modal_calls import CallInspector, CallOutcome + +logger = logging.getLogger(__name__) + +# A claimed call is not checked by another instance for this long; if the +# claiming instance stops, the call becomes due again afterwards. +CHECK_LEASE = timedelta(minutes=1) +# A run left dispatching this long was claimed by an instance that stopped. +DISPATCH_ABANDONED_AFTER = timedelta(minutes=10) +FIRST_BACKOFF = timedelta(seconds=2) +LONGEST_BACKOFF = timedelta(seconds=60) +CLAIM_BATCH_SIZE = 50 +INSPECTION_CONCURRENCY = 10 +BUSY_PASS_INTERVAL_SECONDS = 1.0 +IDLE_PASS_INTERVAL_SECONDS = 5.0 + + +def next_check_delay(check_count: int) -> timedelta: + """Return the wait before the next check of a call checked ``check_count`` times.""" + return min(FIRST_BACKOFF * 2 ** max(check_count - 1, 0), LONGEST_BACKOFF) + + +class SimulationResultRecorder(Protocol): + """Validate one kind of simulation call's output and write its result rows.""" + + def validate(self, output: object) -> object: + """Return the validated result; raise ValueError when it is invalid.""" + ... + + def write( + self, + connection: Connection, + runs: tuple[SimulationRunRecord, ...], + result: object, + ) -> None: + """Write the result rows of ``runs`` in the transaction that succeeds them.""" + ... + + +class ComparisonResultRecorder(Protocol): + """Validate one kind of comparison's output and write its result rows.""" + + def validate(self, output: object) -> object: + """Return the validated result; raise ValueError when it is invalid.""" + ... + + def write( + self, connection: Connection, report_run: ReportRunRecord, result: object + ) -> None: + """Write the comparison's result rows in the transaction that succeeds it.""" + ... + + +@dataclass(frozen=True) +class ResultRecorders: + """The result recorders by kind of run; a kind with none fails its runs.""" + + simulations: Mapping[SimulationKind, SimulationResultRecorder] = field( + default_factory=dict + ) + comparisons: Mapping[ReportKind, ComparisonResultRecorder] = field( + default_factory=dict + ) + + +class RecordingTransitions(Protocol): + """The ``RunTransitions`` writes the loop needs.""" + + def claim_due_simulation_calls( + self, *, limit: int, lease: timedelta + ) -> list[DueSimulationCall]: + """Claim running simulation calls that are due a check.""" + ... + + def record_simulation_call_running( + self, modal_call_id: str, *, next_check_at: datetime + ) -> bool: + """Set a running call's next check.""" + ... + + def complete_simulation_call( + self, + modal_call_id: str, + write_results: Callable[[Connection, tuple[SimulationRunRecord, ...]], None], + ) -> bool: + """Mark a call's runs succeeded and write their results.""" + ... + + def fail_simulation_call(self, modal_call_id: str, error: RunError) -> bool: + """Mark a call's runs failed.""" + ... + + def claim_due_comparisons( + self, *, limit: int, lease: timedelta + ) -> list[ReportRunRecord]: + """Claim running comparison steps that are due a check.""" + ... + + def record_comparison_running( + self, report_run_id: UUID, *, next_check_at: datetime + ) -> bool: + """Set a running comparison step's next check.""" + ... + + def complete_comparison( + self, + report_run_id: UUID, + write_results: Callable[[Connection, ReportRunRecord], None], + ) -> bool: + """Mark a comparison step succeeded and write its results.""" + ... + + def fail_comparison(self, report_run_id: UUID, error: RunError) -> bool: + """Mark a comparison step failed.""" + ... + + def fail_abandoned_dispatches( + self, *, claimed_before: datetime + ) -> AbandonedDispatches: + """Fail runs and comparison steps left dispatching.""" + ... + + +@dataclass +class RecordingPass: + """What one pass found and recorded.""" + + checked: int = 0 + succeeded: int = 0 + failed: int = 0 + still_running: int = 0 + not_reached: int = 0 + abandoned: int = 0 + + @property + def found_work(self) -> bool: + """Whether the pass checked or failed anything.""" + return bool(self.checked or self.abandoned) + + +@dataclass(frozen=True) +class _ResultSteps: + """Validate a finished call's output, then record it with its result rows.""" + + validate: Callable[[object], object] + complete: Callable[[object], bool] + + +@dataclass(frozen=True) +class _CallHandlers: + """How to record one claimed call's outcome, bound to its runs. + + ``result_steps`` is None when no recorder handles the runs' kind. + """ + + subject: dict[str, object] + check_count: int + result_steps: _ResultSteps | None + fail: Callable[[RunError], bool] + keep_running: Callable[[datetime], bool] + + +def _invalid_result(error: ValueError) -> RunError: + count = getattr(error, "error_count", None) + detail = f" with {count()} errors" if callable(count) else "" + return RunError( + RunErrorCode.INVALID_RESULT, + f"The Modal call's output failed validation{detail} ({type(error).__name__}).", + ) + + +@dataclass +class RunRecorder: + """Record the outcomes of due Modal calls, one pass at a time.""" + + transitions: RecordingTransitions + inspector: CallInspector + recorders: ResultRecorders = field(default_factory=ResultRecorders) + clock: Callable[[], datetime] = utc_now + + async def record_due_outcomes(self) -> RecordingPass: + """Run one pass: fail abandoned dispatches, then check every due call.""" + recording = RecordingPass() + abandoned = await asyncio.to_thread( + lambda: self.transitions.fail_abandoned_dispatches( + claimed_before=self.clock() - DISPATCH_ABANDONED_AFTER + ) + ) + recording.abandoned = len(abandoned.simulation_run_ids) + len( + abandoned.report_run_ids + ) + if recording.abandoned: + logger.warning( + "simulation_run_dispatches_abandoned", + extra={ + "simulation_run_ids": [ + str(i) for i in abandoned.simulation_run_ids + ], + "report_run_ids": [str(i) for i in abandoned.report_run_ids], + }, + ) + calls = await asyncio.to_thread( + lambda: self.transitions.claim_due_simulation_calls( + limit=CLAIM_BATCH_SIZE, lease=CHECK_LEASE + ) + ) + comparisons = await asyncio.to_thread( + lambda: self.transitions.claim_due_comparisons( + limit=CLAIM_BATCH_SIZE, lease=CHECK_LEASE + ) + ) + handlers = [ + *(self._simulation_handlers(call) for call in calls), + *(self._comparison_handlers(report_run) for report_run in comparisons), + ] + limit = asyncio.Semaphore(INSPECTION_CONCURRENCY) + + async def check(modal_call_id: str, call_handlers: _CallHandlers) -> None: + async with limit: + await self._check(modal_call_id, call_handlers, recording) + + await asyncio.gather( + *( + check(modal_call_id, call_handlers) + for modal_call_id, call_handlers in handlers + ) + ) + return recording + + def _simulation_handlers( + self, call: DueSimulationCall + ) -> tuple[str, _CallHandlers]: + kinds = {run.simulation_kind for run in call.runs} + recorder = ( + self.recorders.simulations.get(next(iter(kinds))) + if len(kinds) == 1 + else None + ) + call_id = call.modal_call_id + result_steps = None + if recorder is not None: + writer = recorder + result_steps = _ResultSteps( + validate=writer.validate, + complete=lambda result: self.transitions.complete_simulation_call( + call_id, + lambda connection, runs: writer.write(connection, runs, result), + ), + ) + return call_id, _CallHandlers( + subject={ + "modal_call_id": call_id, + "simulation_run_ids": [str(run.id) for run in call.runs], + }, + check_count=max(run.check_count for run in call.runs), + result_steps=result_steps, + fail=lambda error: self.transitions.fail_simulation_call(call_id, error), + keep_running=lambda next_check_at: ( + self.transitions.record_simulation_call_running( + call_id, next_check_at=next_check_at + ) + ), + ) + + def _comparison_handlers( + self, report_run: ReportRunRecord + ) -> tuple[str, _CallHandlers]: + recorder = self.recorders.comparisons.get(report_run.report_kind) + report_run_id = report_run.id + call_id = report_run.comparison_modal_call_id or "" + result_steps = None + if recorder is not None: + writer = recorder + result_steps = _ResultSteps( + validate=writer.validate, + complete=lambda result: self.transitions.complete_comparison( + report_run_id, + lambda connection, record: writer.write(connection, record, result), + ), + ) + return call_id, _CallHandlers( + subject={"modal_call_id": call_id, "report_run_id": str(report_run_id)}, + check_count=report_run.comparison_check_count, + result_steps=result_steps, + fail=lambda error: self.transitions.fail_comparison(report_run_id, error), + keep_running=lambda next_check_at: ( + self.transitions.record_comparison_running( + report_run_id, next_check_at=next_check_at + ) + ), + ) + + async def _check( + self, modal_call_id: str, handlers: _CallHandlers, recording: RecordingPass + ) -> None: + recording.checked += 1 + try: + outcome = await self.inspector.inspect(modal_call_id) + except CallInspectionError as error: + # The claim's lease makes the call due again; nothing to record. + recording.not_reached += 1 + logger.warning( + "simulation_run_check_failed", + extra={**handlers.subject, "error_type": str(error)}, + ) + return + await self._record(outcome, handlers, recording) + + async def _record( + self, outcome: CallOutcome, handlers: _CallHandlers, recording: RecordingPass + ) -> None: + if isinstance(outcome, CallRunning): + next_check_at = self.clock() + next_check_delay(handlers.check_count) + await asyncio.to_thread(handlers.keep_running, next_check_at) + recording.still_running += 1 + return + if isinstance(outcome, CallFailed): + await self._fail(handlers, outcome.error, recording) + return + steps = handlers.result_steps + if steps is None: + await self._fail( + handlers, + RunError( + RunErrorCode.NO_RESULT_RECORDER, + "No result recorder handles this kind of run.", + ), + recording, + ) + return + try: + result = await asyncio.to_thread(steps.validate, outcome.output) + except ValueError as error: + await self._fail(handlers, _invalid_result(error), recording) + return + try: + completed = await asyncio.to_thread(steps.complete, result) + # Writing the result rows failed, for example because the database + # was unreachable: nothing was recorded, and the lease makes the call + # due again. + except Exception as error: + recording.not_reached += 1 + logger.exception( + "simulation_run_result_not_written", + extra={**handlers.subject, "error_type": type(error).__name__}, + ) + return + if completed: + recording.succeeded += 1 + logger.info("simulation_run_succeeded", extra=handlers.subject) + + async def _fail( + self, handlers: _CallHandlers, error: RunError, recording: RecordingPass + ) -> None: + if await asyncio.to_thread(handlers.fail, error): + recording.failed += 1 + logger.warning( + "simulation_run_failed", + extra={**handlers.subject, "error_code": error.code.value}, + ) + + +class RunRecordingLoop: + """Run recording passes in the background until stopped. + + A pass that raises is logged and the loop goes on: recording problems + never stop the instance or fail a request. + """ + + def __init__( + self, + recorder: RunRecorder, + *, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + """Run passes of ``recorder``, waiting between them with ``sleep``.""" + self.recorder = recorder + self._sleep = sleep + self._task: asyncio.Task[None] | None = None + + async def run(self) -> None: + """Record outcomes until cancelled; wait less while there is work.""" + while True: + await self._sleep(IDLE_PASS_INTERVAL_SECONDS) + try: + recording = await self.recorder.record_due_outcomes() + except Exception as error: + logger.exception( + "simulation_run_recording_pass_failed", + extra={"error_type": type(error).__name__}, + ) + continue + while recording.found_work: + await self._sleep(BUSY_PASS_INTERVAL_SECONDS) + try: + recording = await self.recorder.record_due_outcomes() + except Exception as error: + logger.exception( + "simulation_run_recording_pass_failed", + extra={"error_type": type(error).__name__}, + ) + break + + def start(self) -> None: + """Start the loop as a background task of the running event loop.""" + if self._task is None: + self._task = asyncio.create_task(self.run()) + + async def stop(self) -> None: + """Cancel the loop and wait for it to end.""" + if self._task is None: + return + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index e0ca4f1bd..46280ca2e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -6,10 +6,18 @@ from typing import TYPE_CHECKING from policyengine_simulation_persistence import ( + RunTransitions, create_simulation_engine, read_schema_revision, ) +from policyengine_simulation_entry.modal_calls import ModalCallInspector +from policyengine_simulation_entry.run_recording import ( + ResultRecorders, + RunRecorder, + RunRecordingLoop, +) + if TYPE_CHECKING: from sqlalchemy import Engine @@ -37,6 +45,20 @@ async def read_revision(self) -> str | None: """Return the applied migration revision, or None before the first one.""" return await asyncio.to_thread(self._read_revision) + def recording_loop(self) -> RunRecordingLoop: + """Return the loop that records Modal call outcomes on runs. + + No kind of run has a result recorder yet, so a finished call would + fail its runs with ``no_result_recorder``; no route creates runs yet. + """ + return RunRecordingLoop( + RunRecorder( + transitions=RunTransitions(self.engine), + inspector=ModalCallInspector(), + recorders=ResultRecorders(), + ) + ) + def dispose(self) -> None: """Close every pooled connection.""" self.engine.dispose() diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index 1af6983b4..586693a05 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -13,12 +13,25 @@ from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 -from policyengine_simulation_entry.modal_calls import ModalRequest, ModalTarget -from policyengine_simulation_persistence import RunError, RunStatus +from policyengine_simulation_entry.modal_calls import ( + CallInspectionError, + CallRunning, + ModalRequest, + ModalTarget, +) +from policyengine_simulation_persistence import ( + AbandonedDispatches, + DueSimulationCall, + RunError, + RunStatus, + SimulationKind, +) if TYPE_CHECKING: from collections.abc import Collection + from policyengine_simulation_entry.modal_calls import CallOutcome + @dataclass class ManualClock: @@ -52,6 +65,26 @@ class FakeExecution: written_results: list[Any] = field(default_factory=list) +@dataclass(frozen=True) +class FakeRunRecord: + """The run fields the recorder reads from a claimed run.""" + + id: UUID + simulation_kind: SimulationKind + modal_call_id: str | None + check_count: int + + +@dataclass(frozen=True) +class FakeReportRunRecord: + """The report run fields the recorder reads from a claimed comparison.""" + + id: UUID + report_kind: str + comparison_modal_call_id: str | None + comparison_check_count: int + + class FakeRunTransitions: """Conditional status changes over in-memory runs and comparison steps.""" @@ -192,6 +225,181 @@ def record_comparison_dispatch_failure( is not None ) + # --- Recording ------------------------------------------------------- + + def claim_due_simulation_calls( + self, *, limit: int, lease: timedelta + ) -> list[DueSimulationCall]: + """Claim running runs that are due, grouped by call.""" + now = self.clock() + due = [ + run + for run in self.runs.values() + if run.status is RunStatus.RUNNING + and run.next_check_at is not None + and run.next_check_at <= now + ][:limit] + calls: dict[str, list[FakeRunRecord]] = {} + for run in due: + run.next_check_at = now + lease + run.check_count += 1 + assert run.modal_call_id is not None + calls.setdefault(run.modal_call_id, []).append( + FakeRunRecord( + id=run.id, + simulation_kind=SimulationKind(run.kind), + modal_call_id=run.modal_call_id, + check_count=run.check_count, + ) + ) + return [ + DueSimulationCall(modal_call_id=call_id, runs=tuple(runs)) # type: ignore[arg-type] + for call_id, runs in sorted(calls.items()) + ] + + def _call_runs(self, modal_call_id: str) -> list[FakeExecution]: + return [ + run + for run in self.runs.values() + if run.modal_call_id == modal_call_id and run.status is RunStatus.RUNNING + ] + + def record_simulation_call_running( + self, modal_call_id: str, *, next_check_at: datetime + ) -> bool: + """Set the next check of a call's running runs.""" + runs = self._call_runs(modal_call_id) + for run in runs: + run.next_check_at = next_check_at + return bool(runs) + + def complete_simulation_call(self, modal_call_id: str, write_results) -> bool: + """Mark a call's runs succeeded after the writer succeeds.""" + runs = self._call_runs(modal_call_id) + if not runs: + return False + records = tuple( + FakeRunRecord( + id=run.id, + simulation_kind=SimulationKind(run.kind), + modal_call_id=run.modal_call_id, + check_count=run.check_count, + ) + for run in runs + ) + connection = object() + write_results(connection, records) + for run in runs: + run.status = RunStatus.SUCCEEDED + run.next_check_at = None + run.written_results.append(connection) + return True + + def fail_simulation_call(self, modal_call_id: str, error: RunError) -> bool: + """Fail a call's running runs.""" + runs = self._call_runs(modal_call_id) + for run in runs: + run.status = RunStatus.FAILED + run.error = error + run.next_check_at = None + return bool(runs) + + def claim_due_comparisons( + self, *, limit: int, lease: timedelta + ) -> list[FakeReportRunRecord]: + """Claim running comparison steps that are due.""" + now = self.clock() + due = [ + comparison + for comparison in self.comparisons.values() + if comparison.status is RunStatus.RUNNING + and comparison.next_check_at is not None + and comparison.next_check_at <= now + ][:limit] + for comparison in due: + comparison.next_check_at = now + lease + comparison.check_count += 1 + return [ + FakeReportRunRecord( + id=comparison.id, + report_kind="economy", + comparison_modal_call_id=comparison.modal_call_id, + comparison_check_count=comparison.check_count, + ) + for comparison in due + ] + + def record_comparison_running( + self, report_run_id: UUID, *, next_check_at: datetime + ) -> bool: + """Set a running comparison step's next check.""" + return ( + self._change_comparison( + report_run_id, RunStatus.RUNNING, next_check_at=next_check_at + ) + is not None + ) + + def complete_comparison(self, report_run_id: UUID, write_results) -> bool: + """Mark a running comparison step succeeded after the writer succeeds.""" + comparison = self.comparisons[report_run_id] + if comparison.status is not RunStatus.RUNNING: + return False + connection = object() + write_results( + connection, + FakeReportRunRecord( + id=comparison.id, + report_kind="economy", + comparison_modal_call_id=comparison.modal_call_id, + comparison_check_count=comparison.check_count, + ), + ) + comparison.status = RunStatus.SUCCEEDED + comparison.next_check_at = None + comparison.written_results.append(connection) + return True + + def fail_comparison(self, report_run_id: UUID, error: RunError) -> bool: + """Fail a running comparison step.""" + return ( + self._change_comparison( + report_run_id, + RunStatus.RUNNING, + status=RunStatus.FAILED, + error=error, + next_check_at=None, + ) + is not None + ) + + def fail_abandoned_dispatches( + self, *, claimed_before: datetime + ) -> AbandonedDispatches: + """Fail runs and comparison steps dispatching since before the cutoff.""" + failed_runs = [] + for run in self.runs.values(): + if ( + run.status is RunStatus.DISPATCHING + and run.updated_at is not None + and run.updated_at < claimed_before + ): + run.status = RunStatus.FAILED + failed_runs.append(run.id) + failed_comparisons = [] + for comparison in self.comparisons.values(): + if ( + comparison.status is RunStatus.DISPATCHING + and comparison.updated_at is not None + and comparison.updated_at < claimed_before + ): + comparison.status = RunStatus.FAILED + failed_comparisons.append(comparison.id) + return AbandonedDispatches( + simulation_run_ids=tuple(failed_runs), + report_run_ids=tuple(failed_comparisons), + ) + class FakeDispatcher: """Record spawned requests and return call IDs, or fail like Modal.""" @@ -209,6 +417,23 @@ async def spawn(self, request: ModalRequest) -> str: return f"fc-{len(self.requests)}" +class FakeInspector: + """Return a scripted outcome per call ID, or running when none is set.""" + + def __init__(self) -> None: + """Start with every call running.""" + self.outcomes: dict[str, CallOutcome | CallInspectionError] = {} + self.inspected: list[str] = [] + + async def inspect(self, modal_call_id: str) -> CallOutcome: + """Return the scripted outcome, or raise the scripted error.""" + self.inspected.append(modal_call_id) + outcome = self.outcomes.get(modal_call_id, CallRunning()) + if isinstance(outcome, CallInspectionError): + raise outcome + return outcome + + SAMPLE_REQUEST = ModalRequest( target=ModalTarget(application="policyengine-simulation-v2", function="run"), arguments=({"country": "us"},), diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py new file mode 100644 index 000000000..687b36222 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -0,0 +1,366 @@ +"""The background loop that records Modal call outcomes on runs.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta + +import pytest +from conftest import FakeBackend, make_settings +from fastapi.testclient import TestClient +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallInspectionError, + CallSucceeded, +) +from policyengine_simulation_entry.run_recording import ( + BUSY_PASS_INTERVAL_SECONDS, + CHECK_LEASE, + IDLE_PASS_INTERVAL_SECONDS, + RecordingPass, + ResultRecorders, + RunRecorder, + RunRecordingLoop, + next_check_delay, +) +from policyengine_simulation_persistence import ( + HEAD_REVISION, + ReportKind, + RunError, + RunErrorCode, + RunStatus, + SimulationKind, +) +from pydantic import BaseModel +from run_fakes import FakeInspector, FakeRunTransitions, ManualClock + + +class BudgetResult(BaseModel): + budgetary_impact: float + + +class RecordingRecorder: + """Validate outputs as ``BudgetResult`` and remember what it wrote.""" + + def __init__(self, failure: Exception | None = None) -> None: + self.failure = failure + self.writes = [] + + def validate(self, output): + return BudgetResult.model_validate(output) + + def write(self, connection, records, result) -> None: + if self.failure is not None: + raise self.failure + self.writes.append((connection, records, result)) + + +def _recorder(recorders=None): + clock = ManualClock() + transitions = FakeRunTransitions(clock) + inspector = FakeInspector() + recorder = RunRecorder( + transitions=transitions, + inspector=inspector, + recorders=recorders or ResultRecorders(), + clock=clock, + ) + return recorder, transitions, inspector, clock + + +def _run(recorder) -> RecordingPass: + return asyncio.run(recorder.record_due_outcomes()) + + +@pytest.mark.parametrize( + ("check_count", "seconds"), + [(0, 2), (1, 2), (2, 4), (3, 8), (5, 32), (6, 60), (40, 60)], +) +def test_checks_back_off_up_to_a_minute(check_count, seconds) -> None: + assert next_check_delay(check_count) == timedelta(seconds=seconds) + + +def test_a_running_call_is_checked_again_later() -> None: + recorder, transitions, inspector, clock = _recorder() + (run_id,) = transitions.running_call("fc-1") + + recording = _run(recorder) + + assert inspector.inspected == ["fc-1"] + assert recording.still_running == 1 + run = transitions.runs[run_id] + assert run.status is RunStatus.RUNNING + assert run.check_count == 1 + assert run.next_check_at == clock() + timedelta(seconds=2) + assert _run(recorder).checked == 0 + + +def test_a_failed_call_fails_its_runs() -> None: + recorder, transitions, inspector, _ = _recorder() + (run_id,) = transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallFailed( + RunError(RunErrorCode.WORKER_TIMEOUT, "The Modal call timed out.") + ) + + recording = _run(recorder) + + assert recording.failed == 1 + run = transitions.runs[run_id] + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.WORKER_TIMEOUT + + +def test_a_finished_call_is_validated_and_written_with_its_success() -> None: + budget = RecordingRecorder() + recorder, transitions, inspector, _ = _recorder( + ResultRecorders(simulations={SimulationKind.ECONOMY: budget}) + ) + (run_id,) = transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallSucceeded({"budgetary_impact": -1.5}) + + recording = _run(recorder) + + assert recording.succeeded == 1 + run = transitions.runs[run_id] + assert run.status is RunStatus.SUCCEEDED + ((connection, records, result),) = budget.writes + assert [record.id for record in records] == [run_id] + assert result == BudgetResult(budgetary_impact=-1.5) + assert run.written_results == [connection] + + +def test_a_household_pair_is_written_once_for_both_runs() -> None: + household = RecordingRecorder() + recorder, transitions, inspector, _ = _recorder( + ResultRecorders(simulations={SimulationKind.HOUSEHOLD: household}) + ) + pair = transitions.running_call("fc-pair", "household", "household") + inspector.outcomes["fc-pair"] = CallSucceeded({"budgetary_impact": 0}) + + recording = _run(recorder) + + assert recording.checked == 1 + assert inspector.inspected == ["fc-pair"] + ((_, records, _),) = household.writes + assert {record.id for record in records} == set(pair) + assert {transitions.runs[run_id].status for run_id in pair} == {RunStatus.SUCCEEDED} + + +def test_a_kind_without_a_recorder_fails_its_runs() -> None: + recorder, transitions, inspector, _ = _recorder() + (run_id,) = transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallSucceeded({"budgetary_impact": 1}) + + _run(recorder) + + assert transitions.runs[run_id].error.code is RunErrorCode.NO_RESULT_RECORDER + + +def test_an_invalid_output_fails_its_runs_without_repeating_it() -> None: + recorder, transitions, inspector, _ = _recorder( + ResultRecorders(simulations={SimulationKind.ECONOMY: RecordingRecorder()}) + ) + (run_id,) = transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallSucceeded({"budgetary_impact": "household 42"}) + + recording = _run(recorder) + + assert recording.failed == 1 + error = transitions.runs[run_id].error + assert error.code is RunErrorCode.INVALID_RESULT + assert "1 errors" in error.message + assert "household 42" not in error.message + + +def test_an_unreachable_modal_leaves_the_call_for_after_its_lease() -> None: + recorder, transitions, inspector, clock = _recorder() + (run_id,) = transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallInspectionError("ConnectionError") + + recording = _run(recorder) + + assert recording.not_reached == 1 + run = transitions.runs[run_id] + assert run.status is RunStatus.RUNNING + assert run.next_check_at == clock() + CHECK_LEASE + clock.advance(CHECK_LEASE) + assert _run(recorder).checked == 1 + + +def test_a_failed_result_write_records_nothing_and_is_retried() -> None: + recorder, transitions, inspector, clock = _recorder( + ResultRecorders( + simulations={ + SimulationKind.ECONOMY: RecordingRecorder( + failure=ConnectionError("database unreachable") + ) + } + ) + ) + (run_id,) = transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallSucceeded({"budgetary_impact": 1}) + + recording = _run(recorder) + + assert recording.not_reached == 1 + assert recording.succeeded == 0 + run = transitions.runs[run_id] + assert run.status is RunStatus.RUNNING + assert run.next_check_at == clock() + CHECK_LEASE + + +def test_an_outcome_another_instance_recorded_first_is_not_counted() -> None: + recorder, transitions, _, _ = _recorder( + ResultRecorders(simulations={SimulationKind.ECONOMY: RecordingRecorder()}) + ) + (run_id,) = transitions.running_call("fc-1") + + class RacingInspector(FakeInspector): + async def inspect(self, modal_call_id): + transitions.runs[run_id].status = RunStatus.SUCCEEDED + return CallSucceeded({"budgetary_impact": 1}) + + recorder.inspector = RacingInspector() + + recording = _run(recorder) + + assert recording.checked == 1 + assert recording.succeeded == 0 + + +def test_comparison_steps_are_checked_and_recorded() -> None: + comparisons = RecordingRecorder() + recorder, transitions, inspector, clock = _recorder( + ResultRecorders(comparisons={ReportKind.ECONOMY: comparisons}) + ) + finished = transitions.add_comparison( + status=RunStatus.RUNNING, modal_call_id="fc-done", next_check_at=clock() + ) + waiting = transitions.add_comparison( + status=RunStatus.RUNNING, modal_call_id="fc-wait", next_check_at=clock() + ) + inspector.outcomes["fc-done"] = CallSucceeded({"budgetary_impact": 3}) + + recording = _run(recorder) + + assert recording.succeeded == 1 + assert recording.still_running == 1 + assert transitions.comparisons[finished].status is RunStatus.SUCCEEDED + assert [record.id for _, record, _ in comparisons.writes] == [finished] + assert transitions.comparisons[waiting].next_check_at == clock() + timedelta( + seconds=2 + ) + + +def test_dispatches_left_for_ten_minutes_are_failed_by_a_pass() -> None: + recorder, transitions, _, clock = _recorder() + stalled = transitions.add_run(status=RunStatus.DISPATCHING, updated_at=clock()) + clock.advance(timedelta(minutes=10, seconds=1)) + recent = transitions.add_run(status=RunStatus.DISPATCHING, updated_at=clock()) + + recording = _run(recorder) + + assert recording.abandoned == 1 + assert transitions.runs[stalled].status is RunStatus.FAILED + assert transitions.runs[recent].status is RunStatus.DISPATCHING + + +class _ScriptedRecorder: + """Return scripted passes, or raise, and count how often it ran.""" + + def __init__(self, passes) -> None: + self.passes = list(passes) + self.calls = 0 + + async def record_due_outcomes(self) -> RecordingPass: + self.calls += 1 + outcome = self.passes.pop(0) if self.passes else RecordingPass() + if isinstance(outcome, Exception): + raise outcome + return outcome + + +def test_the_loop_waits_less_while_there_is_work_and_survives_failures() -> None: + scripted = _ScriptedRecorder( + [RecordingPass(checked=3), RecordingPass(), RuntimeError("database down")] + ) + waits = [] + + async def sleep(seconds: float) -> None: + waits.append(seconds) + if len(waits) == 5: + raise asyncio.CancelledError + + loop = RunRecordingLoop(scripted, sleep=sleep) # type: ignore[arg-type] + + with pytest.raises(asyncio.CancelledError): + asyncio.run(loop.run()) + + assert waits == [ + IDLE_PASS_INTERVAL_SECONDS, + BUSY_PASS_INTERVAL_SECONDS, + IDLE_PASS_INTERVAL_SECONDS, + IDLE_PASS_INTERVAL_SECONDS, + IDLE_PASS_INTERVAL_SECONDS, + ] + assert scripted.calls == 4 + + +def test_the_loop_starts_and_stops_as_a_background_task() -> None: + scripted = _ScriptedRecorder([]) + + async def lifetime() -> None: + loop = RunRecordingLoop(scripted, sleep=lambda _seconds: asyncio.sleep(0)) # type: ignore[arg-type] + loop.start() + await asyncio.sleep(0.01) + await loop.stop() + await loop.stop() + + asyncio.run(lifetime()) + + assert scripted.calls > 0 + + +class _TrackedLoop: + def __init__(self) -> None: + self.started = False + self.stopped = False + + def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.stopped = True + + +class _DatabaseWithLoop: + def __init__(self) -> None: + self.loop = _TrackedLoop() + + async def read_revision(self): + return HEAD_REVISION + + def recording_loop(self): + return self.loop + + def dispose(self) -> None: + return None + + +def test_each_instance_runs_the_loop_for_its_lifetime() -> None: + database = _DatabaseWithLoop() + app = create_app( + settings=make_settings( + simulation_database_url="postgresql://runtime:secret@db.example/postgres" + ), + backend=FakeBackend(), + auth_dependency=lambda: None, + simulation_database=database, + ) + + with TestClient(app): + assert database.loop.started is True + assert database.loop.stopped is False + + assert database.loop.stopped is True diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database.py b/projects/policyengine-simulation-entry/tests/test_simulation_database.py index 95b1defae..6a7e55547 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database.py @@ -33,6 +33,9 @@ async def read_revision(self): raise self.failure return self.revision + def recording_loop(self): + return None + def dispose(self): self.disposed = True From 58b992dc99f04ac3636d5b0f94a98a10540a618e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:59:31 +0400 Subject: [PATCH 048/124] Keep CPU allocated and always mount sim's Modal token for the recording loop The recording loop runs between requests and asks Modal about runs' calls, so the Simulation Entrypoint deploy now: - passes --no-cpu-throttling to gcloud run deploy in staging and production, so Cloud Run keeps the instance's CPU allocated outside requests. Production already keeps one instance running; staging scales to zero and records outcomes only while an instance runs. - mounts MODAL_TOKEN_ID and MODAL_TOKEN_SECRET on every deploy, from the Secret Manager secrets named by the beta and prod environment variables MODAL_TOKEN_ID_SECRET_NAME and MODAL_TOKEN_SECRET_SECRET_NAME, pinned to their newest enabled versions. Before, they were mounted only when DEPLOY_STAGE12_V2 was true; the Stage 12 block no longer mounts them, and its Modal calls use the same token. A deploy without either name stops before gcloud run deploy. The operator step that creates sim's own Modal token sets those two variables to the new secret names. DEPLOYMENT.md describes both settings, and the persistence README names the REFERENCES grants revision 0003 needs. Co-Authored-By: Claude Opus 5.5 --- .../deploy-cloud-run-simulation-entry.sh | 12 ++- .../README.md | 7 +- .../DEPLOYMENT.md | 19 ++++ .../tests/test_run_recording_deployment.py | 88 +++++++++++++++++++ 4 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 0956ae9a5..05836e82f 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -100,6 +100,8 @@ required_variables=( OLD_GATEWAY_AUTH_CLIENT_ID_VALUE OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME SIMULATION_DATABASE_URL_SECRET_NAME + MODAL_TOKEN_ID_SECRET_NAME + MODAL_TOKEN_SECRET_SECRET_NAME OBSERVABILITY_SERVICE_NAMESPACE OBSERVABILITY_TRACE_PROJECT_ID OTEL_EXPORTER_OTLP_ENDPOINT @@ -143,13 +145,16 @@ add_runtime_secret OLD_GATEWAY_AUTH_CLIENT_SECRET \ # The runtime role's URL for the entrypoint's own simulation schema. The # migration job, which uses a separate migrator URL, runs before this deploy. add_runtime_secret SIMULATION_DATABASE_URL "${SIMULATION_DATABASE_URL_SECRET_NAME}" +# sim's own Modal token: the entrypoint starts runs' Modal calls and its +# background loop asks Modal about them, on every deploy. Until the Stage 12 +# comparison path is removed, its Modal calls use the same token. +add_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}" +add_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}" if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then stage12_variables=( STAGE12_ENABLED_VALUE STAGE12_ARTIFACT_BUCKET_VALUE - MODAL_TOKEN_ID_SECRET_NAME - MODAL_TOKEN_SECRET_SECRET_NAME STAGE12_DATABASE_URL_SECRET_NAME ) for variable_name in "${stage12_variables[@]}"; do @@ -161,8 +166,6 @@ if [[ "${DEPLOY_STAGE12_V2}" == "true" ]]; then || fail "STAGE12_ARTIFACT_BUCKET_VALUE is invalid" validate_secret_reference "${STAGE12_DATABASE_URL_SECRET_NAME}" - add_stage12_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}" - add_stage12_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}" add_stage12_runtime_secret STAGE12_DATABASE_URL "${STAGE12_DATABASE_URL_SECRET_NAME}" fi @@ -211,6 +214,7 @@ gcloud run deploy "${ENTRYPOINT_SERVICE}" \ --env-vars-file "${runtime_environment_file}" \ --set-secrets "${runtime_secrets_csv}" \ --cpu 1 \ + --no-cpu-throttling \ --memory 512Mi \ --concurrency 80 \ --timeout 60 \ diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 0a60b73e3..05394f178 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -91,10 +91,9 @@ privileges on tables the migrator creates later; revision 0002 revokes `UPDATE` on the definition tables, which never change. Both roles must exist before the first migration runs; an operator creates them. Before revision 0002 is deployed, the owner of the Public API's tables must also grant the -migrator `REFERENCES` on `policies`, `households` and `dynamics` (the -operator step grants it on `tax_benefit_model_versions`, `datasets` and -`regions` too, for later revisions); without it the migration fails and -applies nothing. The migrator needs either +migrator `REFERENCES` on `policies`, `households` and `dynamics`, and +before revision 0003 on `tax_benefit_model_versions`, `datasets` and +`regions`; without it the migration fails and applies nothing. The migrator needs either `CREATE` on the database or a `simulation` schema created in advance and owned by it: the environment creates the schema only when it is missing, because PostgreSQL checks `CREATE` on the database even for diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 87ddd1f5e..901063056 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -56,6 +56,25 @@ rollback checks do not, because an older image may run against a newer schema. The ownership rules and roles are described in `libs/policyengine-simulation-persistence/README.md`. +## Recording run outcomes + +Each Simulation Entrypoint instance runs a background loop that asks Modal +about the runs' calls and records their outcomes in the `simulation` schema +(`policyengine_simulation_entry.run_recording`). Two deploy settings serve it: + +- `gcloud run deploy --no-cpu-throttling` keeps the instance's CPU allocated + outside requests, so the loop runs between them. Production keeps at least + one instance running (`entrypoint_min_instances: 1`); staging scales to + zero, so it records outcomes only while an instance runs, and any request + starts one. +- Every deploy mounts sim's own Modal token as `MODAL_TOKEN_ID` and + `MODAL_TOKEN_SECRET`, whatever the Stage 12 setting: the deploy step + resolves the Secret Manager secrets named by the `beta` and `prod` + environment variables `MODAL_TOKEN_ID_SECRET_NAME` and + `MODAL_TOKEN_SECRET_SECRET_NAME` to their newest enabled versions. The + runtime service account must be able to read both secrets. The Stage 12 + comparison path's Modal calls use the same token until it is removed. + ## Candidate qualification For each eligible revision, the workflow first deploys `beta` diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py b/projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py new file mode 100644 index 000000000..ba08a2db6 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py @@ -0,0 +1,88 @@ +"""Deployment settings the background recording loop needs. + +The loop runs between requests, so Cloud Run must keep the instance's CPU +allocated outside them; and it asks Modal about calls, so every deploy mounts +sim's own Modal token, whether or not it also deploys the Stage 12 path. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +from test_deployment_assets_cloud_run import cloud_run_deploy_environment +from test_deployment_assets_support import ( + CLOUD_RUN_DEPLOY_SCRIPT, + REUSABLE_DEPLOY_WORKFLOW, + workflow_jobs, +) + + +def _run_deploy(tmp_path, **overrides): + environment = cloud_run_deploy_environment(tmp_path) | overrides + result = subprocess.run( + [CLOUD_RUN_DEPLOY_SCRIPT], + check=False, + capture_output=True, + text=True, + env=environment, + ) + arguments_capture = Path(environment["GCLOUD_ARGUMENTS_CAPTURE"]) + arguments = ( + arguments_capture.read_text(encoding="utf-8").splitlines() + if arguments_capture.exists() + else [] + ) + return result, arguments + + +@pytest.mark.parametrize("deploy_stage12", ["true", "false"]) +def test_every_deploy_keeps_cpu_allocated_between_requests( + tmp_path, deploy_stage12 +) -> None: + result, arguments = _run_deploy(tmp_path, DEPLOY_STAGE12_V2=deploy_stage12) + + assert result.returncode == 0, result.stderr + assert "--no-cpu-throttling" in arguments + assert "--cpu-throttling" not in arguments + + +@pytest.mark.parametrize("deploy_stage12", ["true", "false"]) +def test_every_deploy_mounts_sims_own_modal_token(tmp_path, deploy_stage12) -> None: + result, arguments = _run_deploy(tmp_path, DEPLOY_STAGE12_V2=deploy_stage12) + + assert result.returncode == 0, result.stderr + secrets = arguments[arguments.index("--set-secrets") + 1].split(",") + assert "MODAL_TOKEN_ID=modal-token-id:3" in secrets + assert "MODAL_TOKEN_SECRET=modal-token-secret:3" in secrets + assert len(secrets) == len(set(secrets)) + + +@pytest.mark.parametrize( + "variable", ["MODAL_TOKEN_ID_SECRET_NAME", "MODAL_TOKEN_SECRET_SECRET_NAME"] +) +def test_a_deploy_without_the_modal_token_stops(tmp_path, variable) -> None: + result, arguments = _run_deploy( + tmp_path, DEPLOY_STAGE12_V2="false", **{variable: ""} + ) + + assert result.returncode != 0 + assert variable in result.stderr + assert arguments == [] + + +def test_the_deploy_step_always_passes_the_modal_token_secret_names() -> None: + deploy_entrypoint = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["deploy_entrypoint"] + environment = next( + step["env"] + for step in deploy_entrypoint["steps"] + if "deploy-cloud-run-simulation-entry.sh" in step.get("run", "") + ) + + assert environment["MODAL_TOKEN_ID_SECRET_NAME"] == ( + "${{ vars.MODAL_TOKEN_ID_SECRET_NAME }}" + ) + assert environment["MODAL_TOKEN_SECRET_SECRET_NAME"] == ( + "${{ vars.MODAL_TOKEN_SECRET_SECRET_NAME }}" + ) From a50d0817d5db4b335d93d5133ef2525db1ac4d3c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:01:21 +0400 Subject: [PATCH 049/124] Load-check the recording loop's conditional writes on PostgreSQL Six simulated instances, each on its own engine, claim due calls and record an outcome for each of 480 running household calls until every run has finished. Some outcomes are recorded only in a later pass, after an instance with nothing to claim has moved the clock past every lease, so another instance can claim the same call and record it first. The test requires every call's results to be written once and every run to finish once, and prints the elapsed time and number of passes as a measurement, with no threshold. With the status condition removed from the call updates, it fails. A staging run of the same check, against the staging database, is left to an operator. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_recording_load.py | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 libs/policyengine-simulation-persistence/tests/test_recording_load.py diff --git a/libs/policyengine-simulation-persistence/tests/test_recording_load.py b/libs/policyengine-simulation-persistence/tests/test_recording_load.py new file mode 100644 index 000000000..d3cc76745 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_recording_load.py @@ -0,0 +1,180 @@ +"""A load check of the recording loop's conditional writes on PostgreSQL. + +Several simulated Simulation Entrypoint instances, each on its own engine, +repeatedly claim due calls and record an outcome for each, as the recording +loop does, until every run has finished. Every call must be recorded once: +its results written once and its runs finished once, whatever the +interleaving. The elapsed time and the number of passes are printed, as a +measurement; nothing depends on them. +""" + +from __future__ import annotations + +import random +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier, Lock + +import pytest +from policyengine_simulation_persistence import ( + RunError, + RunErrorCode, + RunStatus, + RunTransitions, + create_simulation_engine, +) +from policyengine_simulation_persistence.runs import simulation_runs +from run_support import household_run +from simulation_support import household_simulation, simulation_store +from sqlalchemy import func, select +from transition_support import ManualClock, clocked_stores + +pytestmark = pytest.mark.postgres + +RUNS = 480 +INSTANCES = 6 +CLAIM_LIMIT = 20 +LEASE = timedelta(minutes=1) +MAXIMUM_PASSES = 400 + + +def _start_running_calls(database, rows, clock) -> int: + """Create household runs, each running under its own Modal call.""" + stores = clocked_stores(database, clock) + simulations = simulation_store(database) + for index in range(RUNS): + simulation = simulations.create( + household_simulation(rows, legacy_simulation_id=index + 1) + ) + run = stores.runs.start_simulation_run(household_run(simulation, rows)).run + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=f"fc-load-{index}", first_check_at=clock() + ) + stores.transitions.engine.dispose() + return RUNS + + +class _Outcomes: + """Remember every recorded outcome, from every instance.""" + + def __init__(self) -> None: + self.lock = Lock() + self.written_calls: list[str] = [] + self.failed_calls: list[str] = [] + + def write(self, call_id: str): + def write_results(_connection, _runs) -> None: + with self.lock: + self.written_calls.append(call_id) + + return write_results + + +def _record(transitions, outcomes, call_id: str, *, succeed: bool) -> None: + if succeed: + transitions.complete_simulation_call(call_id, outcomes.write(call_id)) + elif transitions.fail_simulation_call( + call_id, RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised.") + ): + with outcomes.lock: + outcomes.failed_calls.append(call_id) + + +def _instance(database, clock, outcomes, barrier, seed: int) -> int: + """Claim and record due calls until none is left; return the pass count. + + Some outcomes are recorded only in a later pass, after an instance with + nothing to claim has moved the clock past every lease, so another + instance may claim the same call and record it first: the late write + must then change nothing. + """ + chooser = random.Random(seed) # noqa: S311 # test interleaving, not security + transitions = RunTransitions( + create_simulation_engine(database.runtime_url), clock=clock + ) + late: list[tuple[str, bool]] = [] + barrier.wait() + passes = 0 + try: + while passes < MAXIMUM_PASSES: + passes += 1 + for call_id, succeed in late: + _record(transitions, outcomes, call_id, succeed=succeed) + late.clear() + calls = transitions.claim_due_simulation_calls( + limit=CLAIM_LIMIT, lease=LEASE + ) + if not calls: + if not _unfinished(transitions): + return passes + clock.advance(LEASE) + continue + for call in calls: + outcome = chooser.random() + if outcome < 0.3: + transitions.record_simulation_call_running( + call.modal_call_id, next_check_at=clock() + ) + elif outcome < 0.5: + late.append((call.modal_call_id, outcome < 0.45)) + else: + _record( + transitions, outcomes, call.modal_call_id, succeed=outcome < 0.9 + ) + return passes + finally: + transitions.engine.dispose() + + +def _unfinished(transitions: RunTransitions) -> int: + with transitions.engine.connect() as connection: + return connection.execute( + select(func.count()).where( + simulation_runs.c.status == RunStatus.RUNNING.value + ) + ).scalar_one() + + +def test_concurrent_instances_record_every_call_once( + migrated_database, public_api_rows, capsys +) -> None: + clock = ManualClock() + calls = _start_running_calls(migrated_database, public_api_rows, clock) + outcomes = _Outcomes() + barrier = Barrier(INSTANCES) + started = time.monotonic() + + with ThreadPoolExecutor(max_workers=INSTANCES) as pool: + passes = list( + pool.map( + lambda seed: _instance( + migrated_database, clock, outcomes, barrier, seed + ), + range(INSTANCES), + ) + ) + + elapsed = time.monotonic() - started + recorded = outcomes.written_calls + outcomes.failed_calls + assert len(recorded) == len(set(recorded)) == calls + stores = clocked_stores(migrated_database, clock) + with stores.transitions.engine.connect() as connection: + statuses = dict( + connection.execute( + select(simulation_runs.c.status, func.count()).group_by( + simulation_runs.c.status + ) + ).all() + ) + stores.transitions.engine.dispose() + assert statuses == { + RunStatus.SUCCEEDED.value: len(outcomes.written_calls), + RunStatus.FAILED.value: len(outcomes.failed_calls), + } + with capsys.disabled(): + print( # noqa: T201 # the measurement this check records + f"\nrecording load check: {calls} calls, {INSTANCES} instances, " + f"{sum(passes)} passes, {elapsed:.2f} s" + ) From a418cd5883090f5d1982c0e83937b346f7dc77f5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:31:40 +0400 Subject: [PATCH 050/124] Cancel a Modal call whose runs cannot record it RunDispatchService handled only a failed spawn. If recording the acknowledged call ID then raised (a dropped connection, or a wait for a pooled connection that ran out), the exception propagated before any log carried the call ID: the runs stayed dispatching until the recording loop failed them as abandoned, with a message saying the instance stopped, while the Modal call ran to its end with nobody collecting its output, and a resubmission started a second call for the same setup. Now a call that cannot be recorded is cancelled (CallDispatcher.cancel, which ModalCallDispatcher implements with FunctionCall.cancel), its runs are failed at once with dispatch_failed when the database allows, and an error log names the call. A call acknowledged after its runs stopped dispatching is cancelled too and logged as a warning. A failed cancel is logged and never raised. Co-Authored-By: Claude Opus 5.5 --- .../modal_calls.py | 10 ++- .../run_dispatch.py | 68 +++++++++++++++--- .../tests/run_fakes.py | 16 ++++- .../tests/test_run_dispatch.py | 71 ++++++++++++++++++- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py index 92f79f0d0..e5eb7154f 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py @@ -66,12 +66,16 @@ class CallInspectionError(RuntimeError): class CallDispatcher(Protocol): - """Start a Modal call without waiting for it.""" + """Start a Modal call without waiting for it, or cancel one.""" async def spawn(self, request: ModalRequest) -> str: """Return the call ID once Modal acknowledges the call.""" ... + async def cancel(self, modal_call_id: str) -> None: + """Ask Modal to stop a call whose runs could not record it.""" + ... + class CallInspector(Protocol): """Ask Modal about a call without waiting for it.""" @@ -108,6 +112,10 @@ async def spawn(self, request: ModalRequest) -> str: raise RuntimeError(msg) return modal_call_id + async def cancel(self, modal_call_id: str) -> None: + """Ask Modal to stop the call; its output will never be collected.""" + await modal.FunctionCall.from_id(modal_call_id).cancel.aio() + # Problems reaching Modal: the call's state is unknown, so ask again later. TRANSIENT_MODAL_ERRORS: tuple[type[Exception], ...] = ( diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index 76eb398bb..97115dd9c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -188,11 +188,63 @@ async def _start( ), ) return DispatchOutcome.DISPATCH_FAILED - recorded = await asyncio.to_thread( - steps.record, modal_call_id, self.clock() + FIRST_CHECK_DELAY - ) - logger.info( - "simulation_run_dispatched", - extra={**subject, "modal_call_id": modal_call_id, "recorded": recorded}, - ) - return DispatchOutcome.STARTED if recorded else DispatchOutcome.NOT_RECORDED + return await self._record(steps, modal_call_id, subject) + + async def _record( + self, steps: _DispatchSteps, modal_call_id: str, subject: dict[str, object] + ) -> DispatchOutcome: + """Record the acknowledged call, or cancel it if it cannot be recorded. + + A call whose runs never record it would run to its end with nobody + collecting its output, and its runs would later be failed as + abandoned; a resubmission would then start a second call. So such a + call is cancelled, and when the record failed, its runs are failed + at once with dispatch_failed if the database allows. + """ + context = {**subject, "modal_call_id": modal_call_id} + try: + recorded = await asyncio.to_thread( + steps.record, modal_call_id, self.clock() + FIRST_CHECK_DELAY + ) + except Exception as error: # the call is cancelled instead + logger.exception( + "simulation_run_call_not_recorded", + extra={**context, "error_type": type(error).__name__}, + ) + await self._cancel(modal_call_id, context) + await self._fail_quietly(steps, context) + return DispatchOutcome.NOT_RECORDED + if not recorded: + # The runs stopped dispatching while Modal acknowledged the call, + # for example because the recording loop failed them as abandoned. + logger.warning("simulation_run_call_not_recorded", extra=context) + await self._cancel(modal_call_id, context) + return DispatchOutcome.NOT_RECORDED + logger.info("simulation_run_dispatched", extra=context) + return DispatchOutcome.STARTED + + async def _cancel(self, modal_call_id: str, context: dict[str, object]) -> None: + try: + await self.dispatcher.cancel(modal_call_id) + except Exception as error: # logged; nothing else can be done + logger.exception( + "simulation_run_call_not_cancelled", + extra={**context, "error_type": type(error).__name__}, + ) + + async def _fail_quietly( + self, steps: _DispatchSteps, context: dict[str, object] + ) -> None: + try: + await asyncio.to_thread( + steps.fail, + RunError( + RunErrorCode.DISPATCH_FAILED, + "The Modal call could not be recorded, so it was cancelled.", + ), + ) + except Exception as error: # the abandoned-dispatch check fails the runs later + logger.exception( + "simulation_run_dispatch_failure_not_recorded", + extra={**context, "error_type": type(error).__name__}, + ) diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index 586693a05..9e7b111d5 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -404,10 +404,22 @@ def fail_abandoned_dispatches( class FakeDispatcher: """Record spawned requests and return call IDs, or fail like Modal.""" - def __init__(self, failure: Exception | None = None) -> None: - """Fail every spawn with ``failure`` when it is set.""" + def __init__( + self, + failure: Exception | None = None, + cancel_failure: Exception | None = None, + ) -> None: + """Fail spawns with ``failure`` and cancels with ``cancel_failure``.""" self.failure = failure + self.cancel_failure = cancel_failure self.requests: list[ModalRequest] = [] + self.cancelled: list[str] = [] + + async def cancel(self, modal_call_id: str) -> None: + """Record the cancelled call, or raise the configured failure.""" + self.cancelled.append(modal_call_id) + if self.cancel_failure is not None: + raise self.cancel_failure async def spawn(self, request: ModalRequest) -> str: """Return a new call ID, or raise the configured failure.""" diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index f4378c4a6..dc2e24cac 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -99,13 +99,12 @@ async def test_a_refused_call_fails_the_runs_without_raising() -> None: @pytest.mark.asyncio -async def test_a_call_whose_runs_stopped_dispatching_is_reported() -> None: +async def test_a_call_whose_runs_stopped_dispatching_is_cancelled(caplog) -> None: service, transitions, _ = _service() run_id = transitions.add_run() class SlowDispatcher(FakeDispatcher): async def spawn(self, request): - # The recording loop gave up on the dispatch in the meantime. transitions.runs[run_id].status = RunStatus.FAILED return await super().spawn(request) @@ -114,7 +113,73 @@ async def spawn(self, request): outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) assert outcome is DispatchOutcome.NOT_RECORDED - assert execution(transitions, run_id).modal_call_id is None + assert service.dispatcher.cancelled == ["fc-1"] + assert "simulation_run_call_not_recorded" in caplog.text + + +@pytest.mark.asyncio +async def test_a_call_that_cannot_be_recorded_is_cancelled_and_its_runs_failed( + caplog, +) -> None: + service, transitions, _ = _service() + run_id = transitions.add_run() + + def unreachable_database(*_arguments, **_keywords): + msg = "connection lost" + raise ConnectionError(msg) + + transitions.record_simulation_dispatched = unreachable_database # type: ignore[method-assign] + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.NOT_RECORDED + assert service.dispatcher.cancelled == ["fc-1"] + run = execution(transitions, run_id) + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.DISPATCH_FAILED + assert "could not be recorded" in run.error.message + assert any( + getattr(record, "modal_call_id", None) == "fc-1" for record in caplog.records + ) + + +@pytest.mark.asyncio +async def test_a_failed_cancel_is_logged_without_raising(caplog) -> None: + service, transitions, _ = _service( + FakeDispatcher(cancel_failure=RuntimeError("modal down")) + ) + run_id = transitions.add_run() + + def unreachable_database(*_arguments, **_keywords): + msg = "connection lost" + raise ConnectionError(msg) + + transitions.record_simulation_dispatched = unreachable_database # type: ignore[method-assign] + transitions.record_simulation_dispatch_failure = unreachable_database # type: ignore[method-assign] + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.NOT_RECORDED + assert "simulation_run_call_not_cancelled" in caplog.text + + +def test_the_dispatcher_cancels_a_call_by_its_id(monkeypatch) -> None: + cancelled = [] + + class _FakeCancel: + async def aio(self): + cancelled.append("cancelled") + + class _FakeCall: + cancel = _FakeCancel() + + monkeypatch.setattr( + modal_calls.modal.FunctionCall, "from_id", lambda call_id: _FakeCall() + ) + + asyncio.run(ModalCallDispatcher("staging").cancel("fc-orphan")) + + assert cancelled == ["cancelled"] @pytest.mark.asyncio From 6839761c146c436f661012286e0a3ae0376db9ee Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:32:59 +0400 Subject: [PATCH 051/124] Fail a comparison step whose simulation run failed A pending comparison step whose baseline or reform simulation run failed could never start and stayed pending, so its report run's status never changed. RunTransitions.fail_comparisons_of_failed_runs fails such steps with simulation_run_failed in one conditional update, and each recording pass runs it next to the abandoned-dispatch check, so a report run reaches a final status without any caller polling. The persistence README now describes the conditional writes and notes that starting a comparison step is left to the stage 2 report path. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 10 +++ .../run_transitions.py | 45 ++++++++++++- .../tests/test_run_transitions.py | 27 ++++++++ .../run_recording.py | 65 +++++++++++++------ .../tests/run_fakes.py | 26 +++++++- .../tests/test_run_recording.py | 16 +++++ 6 files changed, 166 insertions(+), 23 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 05394f178..7e629f5e7 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -52,6 +52,16 @@ and takes the catalog version and year from them; a report's status is its newest report run's. The runtime role may read, insert and update runs but not delete them. +`RunTransitions` makes every status change one conditional update on the +expected status, so a second writer changes nothing: only the caller that +moves runs from `pending` to `dispatching` starts their Modal call, the runs +one call computes change together, the recording loop claims a due call by +moving its next check time, results are written in the transaction that +marks a run succeeded, and dispatches left `dispatching` (and comparison +steps whose simulation run failed) are failed so the work can be submitted +again. A comparison step can start only once both of its simulation runs +have succeeded; starting it is left to the stage 2 report path. + ## Ownership rules These rules are registered in `rules/registry.toml` and checked by diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 39c6ee2c8..89e4197ff 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -29,7 +29,7 @@ from enum import StrEnum from typing import TYPE_CHECKING -from sqlalchemy import and_, exists, select, update +from sqlalchemy import and_, exists, or_, select, update from policyengine_simulation_persistence.run_store import utc_now from policyengine_simulation_persistence.runs import ( @@ -71,6 +71,8 @@ class RunErrorCode(StrEnum): INVALID_RESULT = "invalid_result" # No recorder handles results of this kind of run. NO_RESULT_RECORDER = "no_result_recorder" + # A comparison step whose baseline or reform simulation run failed. + SIMULATION_RUN_FAILED = "simulation_run_failed" @dataclass(frozen=True) @@ -512,6 +514,47 @@ def _comparison_failure(self, error: RunError) -> dict[str, object]: "comparison_next_check_at": None, } + def fail_comparisons_of_failed_runs(self) -> tuple[UUID, ...]: + """Fail pending comparison steps whose baseline or reform run failed. + + Such a step can never start, so it fails with ``simulation_run_failed`` + and its report run's status says so. Return the report runs changed. + """ + baseline = simulation_runs.alias("failed_baseline_run") + reform = simulation_runs.alias("failed_reform_run") + either_run_failed = or_( + exists().where( + and_( + baseline.c.id == report_runs.c.baseline_simulation_run_id, + baseline.c.status == RunStatus.FAILED.value, + ) + ), + exists().where( + and_( + reform.c.id == report_runs.c.reform_simulation_run_id, + reform.c.status == RunStatus.FAILED.value, + ) + ), + ) + failure = self._comparison_failure( + RunError( + RunErrorCode.SIMULATION_RUN_FAILED, + "A simulation run this comparison needs failed.", + ) + ) + with self.engine.begin() as connection: + return tuple( + connection.execute( + update(report_runs) + .where( + report_runs.c.comparison_status == RunStatus.PENDING.value, + either_run_failed, + ) + .values(updated_at=self._clock(), **failure) + .returning(report_runs.c.id) + ).scalars() + ) + # --- Dispatches that never finished ------------------------------------ def fail_abandoned_dispatches( diff --git a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py index 689683849..d08655317 100644 --- a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py @@ -489,3 +489,30 @@ def test_dispatches_left_for_ten_minutes_are_failed( == "dispatch_abandoned" ) assert stores.runs.get_simulation_run(recent_run.id).status is RunStatus.DISPATCHING + + +def test_a_comparison_of_a_failed_run_fails_too( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + waiting, _ = _economy_report_run(stores, migrated_database, public_api_rows) + _succeed(stores, baseline_run, "fc-baseline") + stores.transitions.claim_simulation_dispatch([reform_run.id]) + stores.transitions.record_simulation_dispatched( + [reform_run.id], modal_call_id="fc-reform", first_check_at=stores.clock() + ) + stores.transitions.fail_simulation_call("fc-reform", WORKER_ERROR) + + failed = stores.transitions.fail_comparisons_of_failed_runs() + + assert failed == (report_run.id,) + record = stores.runs.get_report_run(report_run.id) + assert record.comparison_status is RunStatus.FAILED + assert record.comparison_error_code == "simulation_run_failed" + assert stores.runs.get_report_run(waiting.id).comparison_status is ( + RunStatus.PENDING + ) + assert stores.transitions.fail_comparisons_of_failed_runs() == () diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 126392609..859a04b4c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -3,7 +3,8 @@ Each Simulation Entrypoint instance runs one loop. On every pass it: 1. fails runs and comparison steps left ``dispatching`` for ten minutes, so - they can be submitted again; + they can be submitted again, and pending comparison steps whose baseline + or reform run failed, since they can never start; 2. claims the running calls that are due a check, by moving their next check one lease ahead, so instances do not check the same call at once; 3. asks Modal about each claimed call without waiting, and records what it @@ -173,6 +174,10 @@ def fail_abandoned_dispatches( """Fail runs and comparison steps left dispatching.""" ... + def fail_comparisons_of_failed_runs(self) -> tuple[UUID, ...]: + """Fail pending comparison steps whose simulation runs failed.""" + ... + @dataclass class RecordingPass: @@ -184,11 +189,12 @@ class RecordingPass: still_running: int = 0 not_reached: int = 0 abandoned: int = 0 + comparisons_without_runs: int = 0 @property def found_work(self) -> bool: """Whether the pass checked or failed anything.""" - return bool(self.checked or self.abandoned) + return bool(self.checked or self.abandoned or self.comparisons_without_runs) @dataclass(frozen=True) @@ -234,24 +240,7 @@ class RunRecorder: async def record_due_outcomes(self) -> RecordingPass: """Run one pass: fail abandoned dispatches, then check every due call.""" recording = RecordingPass() - abandoned = await asyncio.to_thread( - lambda: self.transitions.fail_abandoned_dispatches( - claimed_before=self.clock() - DISPATCH_ABANDONED_AFTER - ) - ) - recording.abandoned = len(abandoned.simulation_run_ids) + len( - abandoned.report_run_ids - ) - if recording.abandoned: - logger.warning( - "simulation_run_dispatches_abandoned", - extra={ - "simulation_run_ids": [ - str(i) for i in abandoned.simulation_run_ids - ], - "report_run_ids": [str(i) for i in abandoned.report_run_ids], - }, - ) + await self._fail_stopped_work(recording) calls = await asyncio.to_thread( lambda: self.transitions.claim_due_simulation_calls( limit=CLAIM_BATCH_SIZE, lease=CHECK_LEASE @@ -280,6 +269,42 @@ async def check(modal_call_id: str, call_handlers: _CallHandlers) -> None: ) return recording + async def _fail_stopped_work(self, recording: RecordingPass) -> None: + """Fail dispatches that stopped, and comparisons that can never start.""" + abandoned = await asyncio.to_thread( + lambda: self.transitions.fail_abandoned_dispatches( + claimed_before=self.clock() - DISPATCH_ABANDONED_AFTER + ) + ) + recording.abandoned = len(abandoned.simulation_run_ids) + len( + abandoned.report_run_ids + ) + if recording.abandoned: + logger.warning( + "simulation_run_dispatches_abandoned", + extra={ + "simulation_run_ids": [ + str(run_id) for run_id in abandoned.simulation_run_ids + ], + "report_run_ids": [ + str(report_run_id) for report_run_id in abandoned.report_run_ids + ], + }, + ) + without_runs = await asyncio.to_thread( + self.transitions.fail_comparisons_of_failed_runs + ) + recording.comparisons_without_runs = len(without_runs) + if without_runs: + logger.warning( + "report_run_comparisons_failed_with_their_runs", + extra={ + "report_run_ids": [ + str(report_run_id) for report_run_id in without_runs + ] + }, + ) + def _simulation_handlers( self, call: DueSimulationCall ) -> tuple[str, _CallHandlers]: diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index 9e7b111d5..799ed4f0e 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -23,6 +23,7 @@ AbandonedDispatches, DueSimulationCall, RunError, + RunErrorCode, RunStatus, SimulationKind, ) @@ -96,6 +97,7 @@ def __init__(self, clock: ManualClock) -> None: self.runs: dict[UUID, FakeExecution] = {} self.comparisons: dict[UUID, FakeExecution] = {} self.comparison_runs_succeeded: dict[UUID, bool] = {} + self.comparisons_with_failed_runs: set[UUID] = set() # --- Building state -------------------------------------------------- @@ -105,11 +107,17 @@ def add_run(self, kind: str = "economy", **values: Any) -> UUID: self.runs[run.id] = run return run.id - def add_comparison(self, *, runs_succeeded: bool = True, **values: Any) -> UUID: + def add_comparison( + self, *, runs_succeeded: bool = True, runs_failed: bool = False, **values: Any + ) -> UUID: """Add an economy report run's comparison step and return its ID.""" comparison = FakeExecution(id=uuid4(), kind="economy", **values) self.comparisons[comparison.id] = comparison - self.comparison_runs_succeeded[comparison.id] = runs_succeeded + self.comparison_runs_succeeded[comparison.id] = ( + runs_succeeded and not runs_failed + ) + if runs_failed: + self.comparisons_with_failed_runs.add(comparison.id) return comparison.id def running_call(self, call_id: str, *kinds: str) -> list[UUID]: @@ -373,6 +381,20 @@ def fail_comparison(self, report_run_id: UUID, error: RunError) -> bool: is not None ) + def fail_comparisons_of_failed_runs(self) -> tuple[UUID, ...]: + """Fail pending comparison steps whose simulation runs failed.""" + failed = [] + for comparison_id in sorted(self.comparisons_with_failed_runs): + changed = self._change_comparison( + comparison_id, + RunStatus.PENDING, + status=RunStatus.FAILED, + error=RunError(RunErrorCode.SIMULATION_RUN_FAILED, "A run failed."), + ) + if changed is not None: + failed.append(comparison_id) + return tuple(failed) + def fail_abandoned_dispatches( self, *, claimed_before: datetime ) -> AbandonedDispatches: diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index 687b36222..9a856bd19 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -266,6 +266,22 @@ def test_dispatches_left_for_ten_minutes_are_failed_by_a_pass() -> None: assert transitions.runs[recent].status is RunStatus.DISPATCHING +def test_a_pass_fails_comparisons_whose_simulation_run_failed() -> None: + recorder, transitions, _, _ = _recorder() + stuck = transitions.add_comparison(runs_failed=True) + waiting = transitions.add_comparison(runs_succeeded=False) + + recording = _run(recorder) + + assert recording.comparisons_without_runs == 1 + assert recording.found_work + assert transitions.comparisons[stuck].status is RunStatus.FAILED + assert transitions.comparisons[stuck].error.code is ( + RunErrorCode.SIMULATION_RUN_FAILED + ) + assert transitions.comparisons[waiting].status is RunStatus.PENDING + + class _ScriptedRecorder: """Return scripted passes, or raise, and count how often it ran.""" From 6ce4d6e8831fff92427461df0c270633f8b73939 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:34:39 +0400 Subject: [PATCH 052/124] Leave a finished call of a kind without a recorder for a later check During a deploy the candidate revision runs beside the current one, and with CPU always allocated an idle older instance keeps running its loop. An older revision with no result recorder for a run's kind failed the run with no_result_recorder, a final status, although the newer revision would have recorded it. Such a call now gets a later check, backing off like a running call, and a warning names it; a revision with the recorder records it, and Modal's output retention bounds the wait (the call then fails with result_expired). The no_result_recorder error code is gone. Co-Authored-By: Claude Opus 5.5 --- .../run_transitions.py | 2 -- .../run_recording.py | 20 +++++++++++-------- .../simulation_database.py | 3 ++- .../tests/test_run_recording.py | 13 ++++++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 89e4197ff..ac4c326f1 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -69,8 +69,6 @@ class RunErrorCode(StrEnum): CALL_NOT_FOUND = "call_not_found" # The output failed validation against its result model. INVALID_RESULT = "invalid_result" - # No recorder handles results of this kind of run. - NO_RESULT_RECORDER = "no_result_recorder" # A comparison step whose baseline or reform simulation run failed. SIMULATION_RUN_FAILED = "simulation_run_failed" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 859a04b4c..e0c19fec4 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -11,7 +11,8 @@ found with conditional writes: a finished call's result is validated and its result rows are written in the transaction that marks its runs ``succeeded``; a failed or timed-out call is marked ``failed`` with its - error; a call still running gets a later check, backing off. + error; a call still running, or finished but of a kind this revision has + no recorder for, gets a later check, backing off. Pre-emption is never a failure: Modal re-runs a pre-empted call under the same call ID, so it reads as still running. No caller polls, and Modal holds @@ -190,6 +191,7 @@ class RecordingPass: not_reached: int = 0 abandoned: int = 0 comparisons_without_runs: int = 0 + without_recorder: int = 0 @property def found_work(self) -> bool: @@ -397,13 +399,15 @@ async def _record( return steps = handlers.result_steps if steps is None: - await self._fail( - handlers, - RunError( - RunErrorCode.NO_RESULT_RECORDER, - "No result recorder handles this kind of run.", - ), - recording, + # During a deploy an older revision runs beside the new one, and + # with CPU always allocated its loop keeps checking calls. A + # revision without a recorder for this kind leaves the call for + # one that has it; Modal's output retention bounds the wait. + next_check_at = self.clock() + next_check_delay(handlers.check_count) + await asyncio.to_thread(handlers.keep_running, next_check_at) + recording.without_recorder += 1 + logger.warning( + "simulation_run_without_result_recorder", extra=handlers.subject ) return try: diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index 46280ca2e..43f595bbb 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -49,7 +49,8 @@ def recording_loop(self) -> RunRecordingLoop: """Return the loop that records Modal call outcomes on runs. No kind of run has a result recorder yet, so a finished call would - fail its runs with ``no_result_recorder``; no route creates runs yet. + stay running until a revision with a recorder records it; no route + creates runs yet. """ return RunRecordingLoop( RunRecorder( diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index 9a856bd19..add632eba 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -147,14 +147,19 @@ def test_a_household_pair_is_written_once_for_both_runs() -> None: assert {transitions.runs[run_id].status for run_id in pair} == {RunStatus.SUCCEEDED} -def test_a_kind_without_a_recorder_fails_its_runs() -> None: - recorder, transitions, inspector, _ = _recorder() +def test_a_kind_without_a_recorder_waits_for_an_instance_with_one() -> None: + """An older revision still running beside a new one must not fail the run.""" + recorder, transitions, inspector, clock = _recorder() (run_id,) = transitions.running_call("fc-1") inspector.outcomes["fc-1"] = CallSucceeded({"budgetary_impact": 1}) - _run(recorder) + recording = _run(recorder) - assert transitions.runs[run_id].error.code is RunErrorCode.NO_RESULT_RECORDER + assert recording.without_recorder == 1 + run = transitions.runs[run_id] + assert run.status is RunStatus.RUNNING + assert run.error is None + assert run.next_check_at == clock() + timedelta(seconds=2) def test_an_invalid_output_fails_its_runs_without_repeating_it() -> None: From 4f7e51c52fc75212da1a6f4bd51fb6e8e3b5bfd8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:36:41 +0400 Subject: [PATCH 053/124] Leave database connections for requests, and log comparisons as comparisons - The recording loop checked up to ten calls at once, each writing through the runtime engine's pool of two connections plus three overflow, which requests share; a busy pass could leave requests waiting for a connection. It now checks at most three calls at once, and a test keeps that below the pool's size. - Comparison steps were logged under simulation_run_* event names. The dispatch service and the loop now name events for a comparison step report_run_comparison_* (for example report_run_comparison_failed). - Run IDs in log fields are no longer built with a one-letter name. Co-Authored-By: Claude Opus 5.5 --- .../run_dispatch.py | 32 +++++++++++++------ .../run_recording.py | 28 ++++++++++++---- .../tests/test_run_dispatch.py | 5 ++- .../tests/test_run_recording.py | 22 +++++++++++++ 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index 97115dd9c..fd5053501 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -33,6 +33,12 @@ logger = logging.getLogger(__name__) + +def _event_name(prefix: str, outcome: str) -> str: + """Name a log event for a simulation run or a comparison step.""" + return f"{prefix}_{outcome}" + + # The first check of a new call; the recording loop backs off from there. FIRST_CHECK_DELAY = timedelta(seconds=2) @@ -102,6 +108,8 @@ class _DispatchSteps: claim: Callable[[], bool] record: Callable[[str, datetime], bool] fail: Callable[[RunError], bool] + # "simulation_run" or "report_run_comparison": the log event names' start. + event_prefix: str @dataclass @@ -132,6 +140,7 @@ async def start_simulation_runs( fail=lambda error: self.transitions.record_simulation_dispatch_failure( runs, error ), + event_prefix="simulation_run", ), request, subject={"simulation_run_ids": sorted(str(run_id) for run_id in runs)}, @@ -158,6 +167,7 @@ async def start_comparison( fail=lambda error: self.transitions.record_comparison_dispatch_failure( report_run_id, error ), + event_prefix="report_run_comparison", ), request, subject={"report_run_id": str(report_run_id)}, @@ -177,7 +187,7 @@ async def _start( # Any failure to start the call fails the runs; they can be run again. except Exception as error: # noqa: BLE001 # recorded on the runs logger.warning( - "simulation_run_dispatch_failed", + _event_name(steps.event_prefix, "dispatch_failed"), extra={**subject, "error_type": type(error).__name__}, ) await asyncio.to_thread( @@ -208,27 +218,31 @@ async def _record( ) except Exception as error: # the call is cancelled instead logger.exception( - "simulation_run_call_not_recorded", + _event_name(steps.event_prefix, "call_not_recorded"), extra={**context, "error_type": type(error).__name__}, ) - await self._cancel(modal_call_id, context) + await self._cancel(modal_call_id, context, steps.event_prefix) await self._fail_quietly(steps, context) return DispatchOutcome.NOT_RECORDED if not recorded: # The runs stopped dispatching while Modal acknowledged the call, # for example because the recording loop failed them as abandoned. - logger.warning("simulation_run_call_not_recorded", extra=context) - await self._cancel(modal_call_id, context) + logger.warning( + _event_name(steps.event_prefix, "call_not_recorded"), extra=context + ) + await self._cancel(modal_call_id, context, steps.event_prefix) return DispatchOutcome.NOT_RECORDED - logger.info("simulation_run_dispatched", extra=context) + logger.info(_event_name(steps.event_prefix, "dispatched"), extra=context) return DispatchOutcome.STARTED - async def _cancel(self, modal_call_id: str, context: dict[str, object]) -> None: + async def _cancel( + self, modal_call_id: str, context: dict[str, object], event_prefix: str + ) -> None: try: await self.dispatcher.cancel(modal_call_id) except Exception as error: # logged; nothing else can be done logger.exception( - "simulation_run_call_not_cancelled", + _event_name(event_prefix, "call_not_cancelled"), extra={**context, "error_type": type(error).__name__}, ) @@ -245,6 +259,6 @@ async def _fail_quietly( ) except Exception as error: # the abandoned-dispatch check fails the runs later logger.exception( - "simulation_run_dispatch_failure_not_recorded", + _event_name(steps.event_prefix, "dispatch_failure_not_recorded"), extra={**context, "error_type": type(error).__name__}, ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index e0c19fec4..b910a81e7 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -57,6 +57,12 @@ logger = logging.getLogger(__name__) + +def _event_name(prefix: str, outcome: str) -> str: + """Name a log event for a simulation run or a comparison step.""" + return f"{prefix}_{outcome}" + + # A claimed call is not checked by another instance for this long; if the # claiming instance stops, the call becomes due again afterwards. CHECK_LEASE = timedelta(minutes=1) @@ -65,7 +71,10 @@ FIRST_BACKOFF = timedelta(seconds=2) LONGEST_BACKOFF = timedelta(seconds=60) CLAIM_BATCH_SIZE = 50 -INSPECTION_CONCURRENCY = 10 +# Each check writes through the runtime engine's small pool, which requests +# share (policyengine_simulation_persistence.engine: 2 connections plus 3 +# overflow), so the loop uses at most three at once. +INSPECTION_CONCURRENCY = 3 BUSY_PASS_INTERVAL_SECONDS = 1.0 IDLE_PASS_INTERVAL_SECONDS = 5.0 @@ -215,6 +224,8 @@ class _CallHandlers: """ subject: dict[str, object] + # "simulation_run" or "report_run_comparison": the log event names' start. + event_prefix: str check_count: int result_steps: _ResultSteps | None fail: Callable[[RunError], bool] @@ -332,6 +343,7 @@ def _simulation_handlers( "modal_call_id": call_id, "simulation_run_ids": [str(run.id) for run in call.runs], }, + event_prefix="simulation_run", check_count=max(run.check_count for run in call.runs), result_steps=result_steps, fail=lambda error: self.transitions.fail_simulation_call(call_id, error), @@ -360,6 +372,7 @@ def _comparison_handlers( ) return call_id, _CallHandlers( subject={"modal_call_id": call_id, "report_run_id": str(report_run_id)}, + event_prefix="report_run_comparison", check_count=report_run.comparison_check_count, result_steps=result_steps, fail=lambda error: self.transitions.fail_comparison(report_run_id, error), @@ -380,7 +393,7 @@ async def _check( # The claim's lease makes the call due again; nothing to record. recording.not_reached += 1 logger.warning( - "simulation_run_check_failed", + _event_name(handlers.event_prefix, "check_failed"), extra={**handlers.subject, "error_type": str(error)}, ) return @@ -407,7 +420,8 @@ async def _record( await asyncio.to_thread(handlers.keep_running, next_check_at) recording.without_recorder += 1 logger.warning( - "simulation_run_without_result_recorder", extra=handlers.subject + _event_name(handlers.event_prefix, "without_result_recorder"), + extra=handlers.subject, ) return try: @@ -423,13 +437,15 @@ async def _record( except Exception as error: recording.not_reached += 1 logger.exception( - "simulation_run_result_not_written", + _event_name(handlers.event_prefix, "result_not_written"), extra={**handlers.subject, "error_type": type(error).__name__}, ) return if completed: recording.succeeded += 1 - logger.info("simulation_run_succeeded", extra=handlers.subject) + logger.info( + _event_name(handlers.event_prefix, "succeeded"), extra=handlers.subject + ) async def _fail( self, handlers: _CallHandlers, error: RunError, recording: RecordingPass @@ -437,7 +453,7 @@ async def _fail( if await asyncio.to_thread(handlers.fail, error): recording.failed += 1 logger.warning( - "simulation_run_failed", + _event_name(handlers.event_prefix, "failed"), extra={**handlers.subject, "error_code": error.code.value}, ) diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index dc2e24cac..4c01128bf 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -200,7 +200,7 @@ async def test_a_comparison_starts_only_once_its_runs_succeeded() -> None: @pytest.mark.asyncio -async def test_a_refused_comparison_call_fails_the_step() -> None: +async def test_a_refused_comparison_call_fails_the_step(caplog) -> None: service, transitions, _ = _service(FakeDispatcher(failure=RuntimeError("down"))) comparison = transitions.add_comparison() @@ -208,6 +208,9 @@ async def test_a_refused_comparison_call_fails_the_step() -> None: assert outcome is DispatchOutcome.DISPATCH_FAILED assert transitions.comparisons[comparison].status is RunStatus.FAILED + assert [record.message for record in caplog.records] == [ + "report_run_comparison_dispatch_failed" + ] class _FakeCall: diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index add632eba..8d45e2962 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -8,6 +8,7 @@ import pytest from conftest import FakeBackend, make_settings from fastapi.testclient import TestClient +from policyengine_simulation_entry import run_recording from policyengine_simulation_entry.app import create_app from policyengine_simulation_entry.modal_calls import ( CallFailed, @@ -32,6 +33,7 @@ RunStatus, SimulationKind, ) +from policyengine_simulation_persistence.engine import MAX_OVERFLOW, POOL_SIZE from pydantic import BaseModel from run_fakes import FakeInspector, FakeRunTransitions, ManualClock @@ -287,6 +289,26 @@ def test_a_pass_fails_comparisons_whose_simulation_run_failed() -> None: assert transitions.comparisons[waiting].status is RunStatus.PENDING +def test_comparison_outcomes_are_logged_as_comparison_events(caplog) -> None: + recorder, transitions, inspector, clock = _recorder() + transitions.add_comparison( + status=RunStatus.RUNNING, modal_call_id="fc-failed", next_check_at=clock() + ) + inspector.outcomes["fc-failed"] = CallFailed( + RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised KeyError.") + ) + + _run(recorder) + + assert [record.message for record in caplog.records] == [ + "report_run_comparison_failed" + ] + + +def test_the_loop_leaves_database_connections_for_requests() -> None: + assert run_recording.INSPECTION_CONCURRENCY < POOL_SIZE + MAX_OVERFLOW + + class _ScriptedRecorder: """Return scripted passes, or raise, and count how often it ran.""" From e03f440eea5e44bdadcc66e95f0b798bccad1f10 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:37:55 +0400 Subject: [PATCH 054/124] Load-check household pairs and stalled dispatches too The load check covered only single-run calls. A third of its 480 calls are now household pairs (two runs under one call), and 30 runs are left dispatching as if their instance had stopped; every instance also fails stalled dispatches on each pass, as the recording loop does. It requires each call to be recorded once with all of its runs finished, and each stalled dispatch to be failed once. With the dispatching condition removed from the abandoned-dispatch update, it fails. A run against the staging database remains an operator step. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_recording_load.py | 87 ++++++++++++++----- 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/libs/policyengine-simulation-persistence/tests/test_recording_load.py b/libs/policyengine-simulation-persistence/tests/test_recording_load.py index d3cc76745..efdec7d0b 100644 --- a/libs/policyengine-simulation-persistence/tests/test_recording_load.py +++ b/libs/policyengine-simulation-persistence/tests/test_recording_load.py @@ -1,11 +1,13 @@ """A load check of the recording loop's conditional writes on PostgreSQL. Several simulated Simulation Entrypoint instances, each on its own engine, -repeatedly claim due calls and record an outcome for each, as the recording -loop does, until every run has finished. Every call must be recorded once: -its results written once and its runs finished once, whatever the -interleaving. The elapsed time and the number of passes are printed, as a -measurement; nothing depends on them. +repeatedly fail stalled dispatches, claim due calls and record an outcome for +each, as the recording loop does, until every run has finished. A third of +the calls are household pairs (two runs under one call). Every call must be +recorded once, its results written once and all of its runs finished once, +and every stalled dispatch failed once, whatever the interleaving. The +elapsed time and the number of passes are printed, as a measurement; nothing +depends on them. """ from __future__ import annotations @@ -32,28 +34,51 @@ pytestmark = pytest.mark.postgres -RUNS = 480 +CALLS = 480 +# Every third call is a household report's pair: two runs under one call. +PAIR_EVERY = 3 +# Runs claimed by an instance that stopped before recording their call. +STALLED_DISPATCHES = 30 INSTANCES = 6 CLAIM_LIMIT = 20 LEASE = timedelta(minutes=1) +ABANDONED_AFTER = timedelta(minutes=10) MAXIMUM_PASSES = 400 -def _start_running_calls(database, rows, clock) -> int: - """Create household runs, each running under its own Modal call.""" +def _household_run(stores, simulations, rows, legacy_id: int): + simulation = simulations.create( + household_simulation(rows, legacy_simulation_id=legacy_id) + ) + return stores.runs.start_simulation_run(household_run(simulation, rows)).run + + +def _start_running_calls(database, rows, clock) -> dict[str, int]: + """Create running calls, a third of them household pairs, and stalled runs. + + Return each call's number of runs. + """ stores = clocked_stores(database, clock) simulations = simulation_store(database) - for index in range(RUNS): - simulation = simulations.create( - household_simulation(rows, legacy_simulation_id=index + 1) - ) - run = stores.runs.start_simulation_run(household_run(simulation, rows)).run - stores.transitions.claim_simulation_dispatch([run.id]) + legacy_ids = iter(range(1, 10_000)) + runs_per_call = {} + for index in range(CALLS): + count = 2 if index % PAIR_EVERY == 0 else 1 + run_ids = [ + _household_run(stores, simulations, rows, next(legacy_ids)).id + for _ in range(count) + ] + call_id = f"fc-load-{index}" + stores.transitions.claim_simulation_dispatch(run_ids) stores.transitions.record_simulation_dispatched( - [run.id], modal_call_id=f"fc-load-{index}", first_check_at=clock() + run_ids, modal_call_id=call_id, first_check_at=clock() ) + runs_per_call[call_id] = count + for _ in range(STALLED_DISPATCHES): + run = _household_run(stores, simulations, rows, next(legacy_ids)) + stores.transitions.claim_simulation_dispatch([run.id]) stores.transitions.engine.dispose() - return RUNS + return runs_per_call class _Outcomes: @@ -63,6 +88,7 @@ def __init__(self) -> None: self.lock = Lock() self.written_calls: list[str] = [] self.failed_calls: list[str] = [] + self.abandoned_runs: list[object] = [] def write(self, call_id: str): def write_results(_connection, _runs) -> None: @@ -103,11 +129,19 @@ def _instance(database, clock, outcomes, barrier, seed: int) -> int: for call_id, succeed in late: _record(transitions, outcomes, call_id, succeed=succeed) late.clear() + abandoned = transitions.fail_abandoned_dispatches( + claimed_before=clock() - ABANDONED_AFTER + ) + with outcomes.lock: + outcomes.abandoned_runs.extend(abandoned.simulation_run_ids) calls = transitions.claim_due_simulation_calls( limit=CLAIM_LIMIT, lease=LEASE ) if not calls: - if not _unfinished(transitions): + if ( + not _unfinished(transitions) + and len(outcomes.abandoned_runs) == STALLED_DISPATCHES + ): return passes clock.advance(LEASE) continue @@ -141,7 +175,7 @@ def test_concurrent_instances_record_every_call_once( migrated_database, public_api_rows, capsys ) -> None: clock = ManualClock() - calls = _start_running_calls(migrated_database, public_api_rows, clock) + runs_per_call = _start_running_calls(migrated_database, public_api_rows, clock) outcomes = _Outcomes() barrier = Barrier(INSTANCES) started = time.monotonic() @@ -158,7 +192,9 @@ def test_concurrent_instances_record_every_call_once( elapsed = time.monotonic() - started recorded = outcomes.written_calls + outcomes.failed_calls - assert len(recorded) == len(set(recorded)) == calls + assert sorted(recorded) == sorted(runs_per_call) + assert len(outcomes.abandoned_runs) == len(set(outcomes.abandoned_runs)) + assert len(outcomes.abandoned_runs) == STALLED_DISPATCHES stores = clocked_stores(migrated_database, clock) with stores.transitions.engine.connect() as connection: statuses = dict( @@ -170,11 +206,16 @@ def test_concurrent_instances_record_every_call_once( ) stores.transitions.engine.dispose() assert statuses == { - RunStatus.SUCCEEDED.value: len(outcomes.written_calls), - RunStatus.FAILED.value: len(outcomes.failed_calls), + RunStatus.SUCCEEDED.value: sum( + runs_per_call[call_id] for call_id in outcomes.written_calls + ), + RunStatus.FAILED.value: STALLED_DISPATCHES + + sum(runs_per_call[call_id] for call_id in outcomes.failed_calls), } with capsys.disabled(): print( # noqa: T201 # the measurement this check records - f"\nrecording load check: {calls} calls, {INSTANCES} instances, " - f"{sum(passes)} passes, {elapsed:.2f} s" + f"\nrecording load check: {len(runs_per_call)} calls " + f"({sum(runs_per_call.values())} runs), {STALLED_DISPATCHES} stalled " + f"dispatches, {INSTANCES} instances, {sum(passes)} passes, " + f"{elapsed:.2f} s" ) From 6e9a45afa72f3d512570a958e957889dd4add586 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:58:51 +0400 Subject: [PATCH 055/124] Contain a failed check within its pass, and retry client-side Modal errors - RunRecorder gathered a pass's checks without catching their errors. A database error in one check's write (for example a dropped connection) made the pass raise while its other checks kept running, so the next pass started beside them and the three-check limit no longer held, with their exceptions never collected. Each check now ends on any error: it is logged as check_failed, counted as not reached, and the call is due again when its claim's lease ends. A pass returns only once all of its checks have. - ModalCallInspector treated only five Modal exception types as problems reaching Modal; any other error, including a permission or version error, a closed client or a grpclib stream or status error from the client itself, failed the runs as worker_error while their call might still be running. Those are now retried later too. Errors the worker raised still fail the runs. - Comparison steps failed as abandoned are logged as report_run_comparison_dispatches_abandoned, apart from simulation runs. Co-Authored-By: Claude Opus 5.5 --- .../modal_calls.py | 10 ++++- .../run_recording.py | 40 +++++++++--------- .../tests/test_run_dispatch.py | 17 +++++++- .../tests/test_run_recording.py | 41 +++++++++++++++++++ 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py index e5eb7154f..a4ec22e3d 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from typing import Any, Protocol +import grpclib.exceptions import modal from policyengine_simulation_persistence import RunError, RunErrorCode @@ -117,13 +118,20 @@ async def cancel(self, modal_call_id: str) -> None: await modal.FunctionCall.from_id(modal_call_id).cancel.aio() -# Problems reaching Modal: the call's state is unknown, so ask again later. +# Problems between this client and Modal: the call's state is unknown, so ask +# again later. Errors the worker raised come back as their own types (or +# RemoteError, InternalFailure, FunctionTimeoutError) and fail the run. TRANSIENT_MODAL_ERRORS: tuple[type[Exception], ...] = ( modal.exception.ConnectionError, modal.exception.AuthError, + modal.exception.PermissionDeniedError, modal.exception.ServiceError, modal.exception.ResourceExhaustedError, modal.exception.InternalError, + modal.exception.VersionError, + modal.exception.ClientClosed, + grpclib.exceptions.GRPCError, + grpclib.exceptions.StreamTerminatedError, ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index b910a81e7..25df018b9 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -34,7 +34,6 @@ from policyengine_simulation_entry.modal_calls import ( CallFailed, - CallInspectionError, CallRunning, ) @@ -117,7 +116,11 @@ def write( @dataclass(frozen=True) class ResultRecorders: - """The result recorders by kind of run; a kind with none fails its runs.""" + """The result recorders by kind of run. + + A finished call of a kind with no recorder here is checked again later, + so that a revision which has one records it. + """ simulations: Mapping[SimulationKind, SimulationResultRecorder] = field( default_factory=dict @@ -292,18 +295,15 @@ async def _fail_stopped_work(self, recording: RecordingPass) -> None: recording.abandoned = len(abandoned.simulation_run_ids) + len( abandoned.report_run_ids ) - if recording.abandoned: - logger.warning( - "simulation_run_dispatches_abandoned", - extra={ - "simulation_run_ids": [ - str(run_id) for run_id in abandoned.simulation_run_ids - ], - "report_run_ids": [ - str(report_run_id) for report_run_id in abandoned.report_run_ids - ], - }, - ) + for event_prefix, field_name, ids in ( + ("simulation_run", "simulation_run_ids", abandoned.simulation_run_ids), + ("report_run_comparison", "report_run_ids", abandoned.report_run_ids), + ): + if ids: + logger.warning( + _event_name(event_prefix, "dispatches_abandoned"), + extra={field_name: [str(identifier) for identifier in ids]}, + ) without_runs = await asyncio.to_thread( self.transitions.fail_comparisons_of_failed_runs ) @@ -389,15 +389,17 @@ async def _check( recording.checked += 1 try: outcome = await self.inspector.inspect(modal_call_id) - except CallInspectionError as error: - # The claim's lease makes the call due again; nothing to record. + await self._record(outcome, handlers, recording) + # Modal could not be reached, or a database write failed (for example + # a dropped connection). Each check ends here, so a pass returns only + # once all of its checks have, and its concurrency limit holds. The + # claim's lease makes the call due again; nothing was recorded. + except Exception as error: # noqa: BLE001 # retried after the lease recording.not_reached += 1 logger.warning( _event_name(handlers.event_prefix, "check_failed"), - extra={**handlers.subject, "error_type": str(error)}, + extra={**handlers.subject, "error_type": type(error).__name__}, ) - return - await self._record(outcome, handlers, recording) async def _record( self, outcome: CallOutcome, handlers: _CallHandlers, recording: RecordingPass diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index 4c01128bf..3d6427e0a 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -4,6 +4,8 @@ import asyncio +import grpclib.const +import grpclib.exceptions import modal import pytest from policyengine_simulation_entry import modal_calls @@ -266,8 +268,21 @@ def test_a_failed_call_records_only_the_failure_type(monkeypatch, raised, code) [ modal.exception.ConnectionError("unreachable"), modal.exception.ServiceError("unavailable"), + modal.exception.PermissionDeniedError("token revoked"), + modal.exception.VersionError("client too old"), + modal.exception.ClientClosed("closed"), + grpclib.exceptions.StreamTerminatedError("stream reset"), + grpclib.exceptions.GRPCError(grpclib.const.Status.UNAVAILABLE, "down"), + ], + ids=[ + "connection", + "service", + "permission", + "version", + "client-closed", + "stream-terminated", + "grpc", ], - ids=["connection", "service"], ) def test_a_modal_problem_is_retried_later(monkeypatch, raised) -> None: with pytest.raises(CallInspectionError): diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index 8d45e2962..aa5ae740c 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -309,6 +309,47 @@ def test_the_loop_leaves_database_connections_for_requests() -> None: assert run_recording.INSPECTION_CONCURRENCY < POOL_SIZE + MAX_OVERFLOW +def test_a_database_error_in_one_check_ends_with_its_pass(caplog) -> None: + """Every check finishes before the pass returns, so the limit holds.""" + recorder, transitions, inspector, clock = _recorder() + (broken_run,) = transitions.running_call("fc-broken") + (failed_run,) = transitions.running_call("fc-failed") + inspector.outcomes["fc-failed"] = CallFailed( + RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised KeyError.") + ) + keep_running = transitions.record_simulation_call_running + + def drop_one_connection(modal_call_id, *, next_check_at): + if modal_call_id == "fc-broken": + msg = "connection lost" + raise ConnectionError(msg) + return keep_running(modal_call_id, next_check_at=next_check_at) + + transitions.record_simulation_call_running = drop_one_connection # type: ignore[method-assign] + + recording = _run(recorder) + + assert recording.not_reached == 1 + assert recording.failed == 1 + assert transitions.runs[failed_run].status is RunStatus.FAILED + assert transitions.runs[broken_run].next_check_at == clock() + CHECK_LEASE + assert "simulation_run_check_failed" in [ + record.message for record in caplog.records + ] + + +def test_abandoned_comparison_steps_are_logged_as_comparison_events(caplog) -> None: + recorder, transitions, _, clock = _recorder() + transitions.add_comparison(status=RunStatus.DISPATCHING, updated_at=clock()) + clock.advance(timedelta(minutes=10, seconds=1)) + + _run(recorder) + + assert [record.message for record in caplog.records] == [ + "report_run_comparison_dispatches_abandoned" + ] + + class _ScriptedRecorder: """Return scripted passes, or raise, and count how often it ran.""" From 06f55317f3efec262f0fceac528a700cac50a8c7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:00:43 +0400 Subject: [PATCH 056/124] Index pending comparison steps for the recording pass Each recording pass of each instance looks for pending comparison steps whose simulation run failed, and report_runs had partial indexes only for running and dispatching steps, so that query scanned the whole table. Revision 0003 now adds a partial index on pending comparison steps. The dispatch_failed code's comment also covers a call cancelled because its ID could not be recorded. Co-Authored-By: Claude Opus 5.5 --- .../run_transitions.py | 3 ++- .../src/policyengine_simulation_persistence/runs.py | 6 ++++++ .../versions/0003_simulation_runs_and_report_runs.py | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index ac4c326f1..8d442eeed 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -57,7 +57,8 @@ class RunErrorCode(StrEnum): """Why a run or comparison step failed.""" - # Modal refused the call or never acknowledged it. + # Modal refused the call or never acknowledged it, or the acknowledged + # call could not be recorded and was cancelled. DISPATCH_FAILED = "dispatch_failed" # The instance stopped between claiming the dispatch and recording the call. DISPATCH_ABANDONED = "dispatch_abandoned" diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py index d69b42012..4f886d3e1 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -339,6 +339,12 @@ def _execution_columns(prefix: str, *, required: bool) -> list[Column[Any]]: report_runs.c.comparison_next_check_at, postgresql_where=report_runs.c.comparison_status == RunStatus.RUNNING.value, ) +# Each recording pass looks for pending comparison steps whose run failed. +Index( + "ix_report_runs_pending_comparisons", + report_runs.c.id, + postgresql_where=report_runs.c.comparison_status == RunStatus.PENDING.value, +) Index( "ix_report_runs_comparisons_dispatching", report_runs.c.updated_at, diff --git a/migrations/versions/0003_simulation_runs_and_report_runs.py b/migrations/versions/0003_simulation_runs_and_report_runs.py index 1225bb99f..f7a2b5d43 100644 --- a/migrations/versions/0003_simulation_runs_and_report_runs.py +++ b/migrations/versions/0003_simulation_runs_and_report_runs.py @@ -287,6 +287,13 @@ def _create_report_runs() -> None: schema=SCHEMA, postgresql_where=sa.text("comparison_status = 'running'"), ) + op.create_index( + "ix_report_runs_pending_comparisons", + "report_runs", + ["id"], + schema=SCHEMA, + postgresql_where=sa.text("comparison_status = 'pending'"), + ) op.create_index( "ix_report_runs_comparisons_dispatching", "report_runs", @@ -305,6 +312,7 @@ def upgrade() -> None: def downgrade() -> None: """Drop the tables ``upgrade`` created, with their indexes and keys.""" for index in ( + "ix_report_runs_pending_comparisons", "ix_report_runs_comparisons_dispatching", "ix_report_runs_due_comparison_checks", "ix_report_runs_reform_simulation_run_id", From 4ad7605ac643adfcb913530441c506b3c9d604c5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:40:08 +0400 Subject: [PATCH 057/124] Read a call's outcome by where Modal raised it, not by the exception's type Modal re-raises in this process whatever the worker raised, so a worker's exception can share a type with errors from the entrypoint's own request to Modal. A worker that raised the built-in TimeoutError read as still running; one that raised a Modal or grpclib client error (from its own Modal calls) read as a problem reaching Modal and was asked about again until Modal dropped the output; and a worker's SystemExit or other BaseException escaped the inspector entirely. ModalCallInspector now decides by origin: an exception raised from the finished call's result (Modal's result processing) is the call's outcome, whatever its type, so it fails the run (a call timeout as worker_timeout, anything else as worker_error with its type only). Any other exception came from this process's own request: no output yet still reads as running, including while Modal re-runs a pre-empted call; an expired output or an unknown call fails the run; every other request error is asked about again later. A signal to this process itself (cancellation, KeyboardInterrupt, SystemExit, GeneratorExit) propagates. The tests raise worker exceptions through Modal's own result processing, as a real finished call does. Co-Authored-By: Claude Opus 5.5 --- .../modal_calls.py | 113 ++++++++++------ .../tests/test_run_dispatch.py | 121 +++++++++++++++++- 2 files changed, 188 insertions(+), 46 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py index a4ec22e3d..7e546344e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py @@ -1,9 +1,10 @@ """Start Modal calls and ask about them without waiting, for simulation runs. The Simulation Entrypoint starts each run's Modal call with ``spawn`` and later -asks Modal about it with ``get(timeout=0)``, which returns at once. A call -Modal pre-empted is re-run on the same input under the same call ID, so it -reads as still running and finishes later: pre-emption is never a failure. +asks Modal about it with ``get(timeout=0)``, which returns at once. Modal stops +a pre-empted worker with KeyboardInterrupt, records no result for it and +re-runs the same input under the same call ID, so the call reads as still +running and finishes later: pre-emption is never a failure. These adapters belong to the main path. The Stage 12 comparison path keeps its own Modal code; the two share nothing. @@ -11,10 +12,11 @@ from __future__ import annotations +import asyncio +import traceback from dataclasses import dataclass from typing import Any, Protocol -import grpclib.exceptions import modal from policyengine_simulation_persistence import RunError, RunErrorCode @@ -118,23 +120,30 @@ async def cancel(self, modal_call_id: str) -> None: await modal.FunctionCall.from_id(modal_call_id).cancel.aio() -# Problems between this client and Modal: the call's state is unknown, so ask -# again later. Errors the worker raised come back as their own types (or -# RemoteError, InternalFailure, FunctionTimeoutError) and fail the run. -TRANSIENT_MODAL_ERRORS: tuple[type[Exception], ...] = ( - modal.exception.ConnectionError, - modal.exception.AuthError, - modal.exception.PermissionDeniedError, - modal.exception.ServiceError, - modal.exception.ResourceExhaustedError, - modal.exception.InternalError, - modal.exception.VersionError, - modal.exception.ClientClosed, - grpclib.exceptions.GRPCError, - grpclib.exceptions.StreamTerminatedError, +# Where Modal raises a finished call's own outcome in this process: the +# worker's exception (whatever its type), the call's timeout, or a failure +# Modal records for the call. Anything raised elsewhere came from this +# process's own request to Modal. +_CALL_RESULT_MODULE = "modal._utils.function_utils" +_CALL_RESULT_FUNCTION = "_process_result" +# Signals to this process itself, which must never be taken for an outcome. +OWN_PROCESS_SIGNALS: tuple[type[BaseException], ...] = ( + asyncio.CancelledError, + KeyboardInterrupt, + SystemExit, + GeneratorExit, ) +def raised_from_call_result(error: BaseException) -> bool: + """Return whether Modal raised ``error`` from a finished call's result.""" + return any( + frame.f_globals.get("__name__") == _CALL_RESULT_MODULE + and frame.f_code.co_name == _CALL_RESULT_FUNCTION + for frame, _ in traceback.walk_tb(error.__traceback__) + ) + + def _failed(code: RunErrorCode, message: str) -> CallFailed: return CallFailed(RunError(code, message)) @@ -143,28 +152,54 @@ class ModalCallInspector: """Read a Modal call's output if it has one, without waiting.""" async def inspect(self, modal_call_id: str) -> CallOutcome: - """Return the call's outcome, or raise CallInspectionError.""" + """Return the call's outcome, or raise CallInspectionError. + + A worker's exception is re-raised here by Modal and can be of any + type, including one Modal's client also raises (a built-in + TimeoutError, a Modal or grpclib error) or a BaseException such as + SystemExit. So the outcome is decided by where the exception was + raised: from the finished call's result, it is the call's outcome; + otherwise it came from this process's own request to Modal, and a + signal to this process propagates. + """ try: call = modal.FunctionCall.from_id(modal_call_id) output = await call.get.aio(timeout=0) - # Modal raises the built-in TimeoutError while the call has no output. - except TimeoutError: - return CallRunning() - except modal.exception.OutputExpiredError: - return _failed( - RunErrorCode.RESULT_EXPIRED, "Modal no longer holds the call's output." - ) - except modal.exception.FunctionTimeoutError: - return _failed(RunErrorCode.WORKER_TIMEOUT, "The Modal call timed out.") - except modal.exception.NotFoundError: - return _failed(RunErrorCode.CALL_NOT_FOUND, "Modal does not know the call.") - except TRANSIENT_MODAL_ERRORS as error: - raise CallInspectionError(type(error).__name__) from error - # The call re-raises whatever the worker raised. Record only its type: - # the message may contain calculation inputs. - except Exception as error: # noqa: BLE001 # every worker failure fails the run - return _failed( - RunErrorCode.WORKER_ERROR, - f"The Modal call raised {type(error).__name__}.", - ) + except BaseException as error: + if raised_from_call_result(error): + return _call_failure(error) + if isinstance(error, OWN_PROCESS_SIGNALS) or not isinstance( + error, Exception + ): + raise + return _request_outcome(error) return CallSucceeded(output) + + +def _call_failure(error: BaseException) -> CallFailed: + """Turn a finished call's own outcome into a run failure.""" + if isinstance(error, modal.exception.FunctionTimeoutError): + return _failed(RunErrorCode.WORKER_TIMEOUT, "The Modal call timed out.") + # Record only the type: the message may contain calculation inputs. + return _failed( + RunErrorCode.WORKER_ERROR, f"The Modal call raised {type(error).__name__}." + ) + + +def _request_outcome(error: Exception) -> CallOutcome: + """Read an error from this process's own request to Modal. + + Raise CallInspectionError for a problem reaching Modal, so the call is + asked about again later. + """ + # Modal raises the built-in TimeoutError while the call has no output, + # including while it re-runs a pre-empted call. + if type(error) is TimeoutError: + return CallRunning() + if isinstance(error, modal.exception.OutputExpiredError): + return _failed( + RunErrorCode.RESULT_EXPIRED, "Modal no longer holds the call's output." + ) + if isinstance(error, modal.exception.NotFoundError): + return _failed(RunErrorCode.CALL_NOT_FOUND, "Modal does not know the call.") + raise CallInspectionError(type(error).__name__) from error diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index 3d6427e0a..1bb3ee01f 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -3,11 +3,15 @@ from __future__ import annotations import asyncio +import builtins import grpclib.const import grpclib.exceptions import modal import pytest +from modal._serialization import serialize +from modal._utils.function_utils import _process_result +from modal_proto import api_pb2 from policyengine_simulation_entry import modal_calls from policyengine_simulation_entry.modal_calls import ( CallFailed, @@ -22,7 +26,7 @@ DispatchOutcome, RunDispatchService, ) -from policyengine_simulation_persistence import RunErrorCode, RunStatus +from policyengine_simulation_persistence import RunError, RunErrorCode, RunStatus from run_fakes import ( SAMPLE_REQUEST, FakeDispatcher, @@ -247,20 +251,26 @@ def test_a_finished_call_returns_its_output(monkeypatch) -> None: @pytest.mark.parametrize( ("raised", "code"), [ - (modal.exception.FunctionTimeoutError("slow"), RunErrorCode.WORKER_TIMEOUT), (modal.exception.OutputExpiredError(), RunErrorCode.RESULT_EXPIRED), (modal.exception.NotFoundError("gone"), RunErrorCode.CALL_NOT_FOUND), - (KeyError("household secret"), RunErrorCode.WORKER_ERROR), - (modal.exception.InternalFailure("lost"), RunErrorCode.WORKER_ERROR), ], - ids=["timeout", "expired", "not-found", "worker-error", "internal-failure"], + ids=["expired", "not-found"], ) -def test_a_failed_call_records_only_the_failure_type(monkeypatch, raised, code) -> None: +def test_modal_answers_about_the_call_itself_fail_the_run( + monkeypatch, raised, code +) -> None: outcome = _inspect(monkeypatch, raised) assert isinstance(outcome, CallFailed) assert outcome.error.code is code - assert "secret" not in outcome.error.message + + +def test_a_failed_call_records_only_the_failure_type(monkeypatch) -> None: + outcome = _inspect_finished(monkeypatch, KeyError("household secret")) + + assert outcome == CallFailed( + RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised KeyError.") + ) @pytest.mark.parametrize( @@ -273,6 +283,7 @@ def test_a_failed_call_records_only_the_failure_type(monkeypatch, raised, code) modal.exception.ClientClosed("closed"), grpclib.exceptions.StreamTerminatedError("stream reset"), grpclib.exceptions.GRPCError(grpclib.const.Status.UNAVAILABLE, "down"), + RuntimeError("an unexpected client problem"), ], ids=[ "connection", @@ -282,6 +293,7 @@ def test_a_failed_call_records_only_the_failure_type(monkeypatch, raised, code) "client-closed", "stream-terminated", "grpc", + "other-client-error", ], ) def test_a_modal_problem_is_retried_later(monkeypatch, raised) -> None: @@ -289,6 +301,101 @@ def test_a_modal_problem_is_retried_later(monkeypatch, raised) -> None: _inspect(monkeypatch, raised) +class _FinishedCall: + """A call whose output Modal holds: the worker's exception, or a timeout. + + Reading it goes through Modal's own result processing, which raises the + worker's exception in this process, as a real finished call does. + """ + + def __init__(self, worker_exception, status=None): + self.result = api_pb2.GenericResult( + status=status or api_pb2.GenericResult.GENERIC_STATUS_FAILURE, + data=serialize(worker_exception), + exception=repr(worker_exception), + ) + self.get = self + + async def aio(self, *, timeout): + assert timeout == 0 + return await _process_result( + self.result, api_pb2.DATA_FORMAT_PICKLE, None, None + ) + + +def _inspect_finished(monkeypatch, worker_exception, status=None): + monkeypatch.setattr( + modal_calls.modal.FunctionCall, + "from_id", + lambda call_id: _FinishedCall(worker_exception, status), + ) + return asyncio.run(ModalCallInspector().inspect("fc-test")) + + +@pytest.mark.parametrize( + "worker_exception", + [ + SystemExit(2), + builtins.BaseExceptionGroup("worker", [ValueError("one")]), + KeyboardInterrupt(), + TimeoutError("a child call timed out"), + modal.exception.ConnectionError("the worker's own Modal call failed"), + grpclib.exceptions.GRPCError(grpclib.const.Status.UNAVAILABLE, "down"), + ], + ids=[ + "system-exit", + "exception-group", + "keyboard-interrupt", + "timeout", + "modal-connection", + "grpc", + ], +) +def test_whatever_the_worker_raised_fails_the_run( + monkeypatch, worker_exception +) -> None: + """The worker's own exception, even of a type the client also raises, is final.""" + outcome = _inspect_finished(monkeypatch, worker_exception) + + assert isinstance(outcome, CallFailed) + assert outcome.error.code is RunErrorCode.WORKER_ERROR + assert type(worker_exception).__name__ in outcome.error.message + + +def test_a_call_modal_failed_internally_fails_the_run(monkeypatch) -> None: + outcome = _inspect_finished( + monkeypatch, + RuntimeError("ignored"), + status=api_pb2.GenericResult.GENERIC_STATUS_INTERNAL_FAILURE, + ) + + assert isinstance(outcome, CallFailed) + assert outcome.error.code is RunErrorCode.WORKER_ERROR + + +def test_a_call_modal_timed_out_fails_as_a_timeout(monkeypatch) -> None: + outcome = _inspect_finished( + monkeypatch, + RuntimeError("ignored"), + status=api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT, + ) + + assert outcome == CallFailed( + RunError(RunErrorCode.WORKER_TIMEOUT, "The Modal call timed out.") + ) + + +@pytest.mark.parametrize( + "signal", + [asyncio.CancelledError(), KeyboardInterrupt(), SystemExit(0)], + ids=["cancelled", "keyboard-interrupt", "system-exit"], +) +def test_a_signal_to_this_process_is_not_swallowed(monkeypatch, signal) -> None: + """Only an exception that came from the call's result is the worker's.""" + with pytest.raises(type(signal)): + _inspect(monkeypatch, signal) + + def test_the_dispatcher_passes_observability_as_its_own_argument(monkeypatch) -> None: spawned = {} From 3060b9d4588ddf228926d390f11591914be63a12 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:40:08 +0400 Subject: [PATCH 058/124] Keep the recording loop running whatever a check or pass raises The checks and the loop caught only Exception. Anything else (a BaseExceptionGroup, or another BaseException that is not a signal) ended a pass with its other checks still running, or ended the loop task with no log. Each check and each pass now catch BaseException: the error is logged, the check counts as not reached and its call is due again after the claim's lease, and the loop goes on. Only a signal to this process (cancellation at shutdown, KeyboardInterrupt, SystemExit, GeneratorExit) passes through, so a Cloud Run shutdown still stops the loop. Co-Authored-By: Claude Opus 5.5 --- .../run_recording.py | 49 ++++++++------- .../tests/test_run_recording.py | 63 +++++++++++++++++-- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 25df018b9..c71df51a6 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -33,6 +33,7 @@ from policyengine_simulation_persistence.run_store import utc_now from policyengine_simulation_entry.modal_calls import ( + OWN_PROCESS_SIGNALS, CallFailed, CallRunning, ) @@ -391,10 +392,13 @@ async def _check( outcome = await self.inspector.inspect(modal_call_id) await self._record(outcome, handlers, recording) # Modal could not be reached, or a database write failed (for example - # a dropped connection). Each check ends here, so a pass returns only - # once all of its checks have, and its concurrency limit holds. The - # claim's lease makes the call due again; nothing was recorded. - except Exception as error: # noqa: BLE001 # retried after the lease + # a dropped connection). Each check ends here, whatever was raised + # (only a signal to this process passes), so a pass returns only once + # all of its checks have, and its concurrency limit holds. The claim's + # lease makes the call due again; nothing was recorded. + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise recording.not_reached += 1 logger.warning( _event_name(handlers.event_prefix, "check_failed"), @@ -482,24 +486,27 @@ async def run(self) -> None: """Record outcomes until cancelled; wait less while there is work.""" while True: await self._sleep(IDLE_PASS_INTERVAL_SECONDS) - try: - recording = await self.recorder.record_due_outcomes() - except Exception as error: - logger.exception( - "simulation_run_recording_pass_failed", - extra={"error_type": type(error).__name__}, - ) - continue - while recording.found_work: + recording = await self._pass() + while recording is not None and recording.found_work: await self._sleep(BUSY_PASS_INTERVAL_SECONDS) - try: - recording = await self.recorder.record_due_outcomes() - except Exception as error: - logger.exception( - "simulation_run_recording_pass_failed", - extra={"error_type": type(error).__name__}, - ) - break + recording = await self._pass() + + async def _pass(self) -> RecordingPass | None: + """Run one pass; log and return None if it raised anything. + + Only a signal to this process, such as the cancellation that stops + the loop at shutdown, passes through. + """ + try: + return await self.recorder.record_due_outcomes() + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise + logger.exception( + "simulation_run_recording_pass_failed", + extra={"error_type": type(error).__name__}, + ) + return None def start(self) -> None: """Start the loop as a background task of the running event loop.""" diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index aa5ae740c..e41bec099 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import builtins from datetime import timedelta import pytest @@ -350,6 +351,42 @@ def test_abandoned_comparison_steps_are_logged_as_comparison_events(caplog) -> N ] +class _NotAnException(BaseException): + """An error that derives from BaseException only, like a worker's exit.""" + + +def test_a_non_exception_error_in_one_check_ends_with_its_pass(caplog) -> None: + recorder, transitions, _, clock = _recorder() + (run_id,) = transitions.running_call("fc-broken") + + def raise_a_group(modal_call_id, *, next_check_at): + message = "writes" + raise builtins.BaseExceptionGroup(message, [_NotAnException()]) + + transitions.record_simulation_call_running = raise_a_group # type: ignore[method-assign] + + recording = _run(recorder) + + assert recording.not_reached == 1 + assert transitions.runs[run_id].next_check_at == clock() + CHECK_LEASE + assert "simulation_run_check_failed" in [ + record.message for record in caplog.records + ] + + +def test_a_signal_to_this_process_stops_a_check() -> None: + recorder, transitions, _, _ = _recorder() + transitions.running_call("fc-1") + + def interrupted(modal_call_id, *, next_check_at): + raise KeyboardInterrupt + + transitions.record_simulation_call_running = interrupted # type: ignore[method-assign] + + with pytest.raises(KeyboardInterrupt): + _run(recorder) + + class _ScriptedRecorder: """Return scripted passes, or raise, and count how often it ran.""" @@ -360,20 +397,25 @@ def __init__(self, passes) -> None: async def record_due_outcomes(self) -> RecordingPass: self.calls += 1 outcome = self.passes.pop(0) if self.passes else RecordingPass() - if isinstance(outcome, Exception): + if isinstance(outcome, BaseException): raise outcome return outcome def test_the_loop_waits_less_while_there_is_work_and_survives_failures() -> None: scripted = _ScriptedRecorder( - [RecordingPass(checked=3), RecordingPass(), RuntimeError("database down")] + [ + RecordingPass(checked=3), + RecordingPass(), + RuntimeError("database down"), + builtins.BaseExceptionGroup("pass", [_NotAnException()]), + ] ) waits = [] async def sleep(seconds: float) -> None: waits.append(seconds) - if len(waits) == 5: + if len(waits) == 6: raise asyncio.CancelledError loop = RunRecordingLoop(scripted, sleep=sleep) # type: ignore[arg-type] @@ -387,8 +429,21 @@ async def sleep(seconds: float) -> None: IDLE_PASS_INTERVAL_SECONDS, IDLE_PASS_INTERVAL_SECONDS, IDLE_PASS_INTERVAL_SECONDS, + IDLE_PASS_INTERVAL_SECONDS, ] - assert scripted.calls == 4 + assert scripted.calls == 5 + + +def test_a_signal_to_this_process_stops_the_loop() -> None: + scripted = _ScriptedRecorder([KeyboardInterrupt()]) + + async def sleep(_seconds: float) -> None: + return None + + loop = RunRecordingLoop(scripted, sleep=sleep) # type: ignore[arg-type] + + with pytest.raises(KeyboardInterrupt): + asyncio.run(loop.run()) def test_the_loop_starts_and_stops_as_a_background_task() -> None: From 3f7c117e44597cb8f4292dc9e1ff5cef1aae6ae6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:28:06 +0400 Subject: [PATCH 059/124] Retry a failed output download instead of failing the run From the review of the rebased 1.06: Modal stores any spawned call's output over 8 KiB as a blob, so its result processing (_process_result) first downloads nearly every real result with this process's own requests (a BlobGet request, then an HTTP download). The inspector took any exception with that function in its traceback for the call's outcome, so a dropped connection or a failed download during that step failed a call that had succeeded, and its output was never recorded. Now an exception is the call's outcome only when that function raised it itself, or re-raised the worker's exception (whose own frames follow it in the traceback). When the frame it called is Modal's blob download, the error is this process's own: the call is asked about again later. Tests read a blob-stored output, fail its request and its download, and re-raise a worker exception whose own frames include a blob download. Co-Authored-By: Claude Opus 5.5 --- .../modal_calls.py | 36 ++++-- .../tests/test_run_dispatch.py | 121 +++++++++++++++++- 2 files changed, 147 insertions(+), 10 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py index 7e546344e..96ec899bf 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py @@ -121,11 +121,16 @@ async def cancel(self, modal_call_id: str) -> None: # Where Modal raises a finished call's own outcome in this process: the -# worker's exception (whatever its type), the call's timeout, or a failure -# Modal records for the call. Anything raised elsewhere came from this -# process's own request to Modal. +# worker's exception (whatever its type, followed in the traceback by the +# worker's own frames), the call's timeout, a failure Modal records for the +# call, or an output that cannot be decoded. Anything raised elsewhere came +# from this process's own request to Modal. _CALL_RESULT_MODULE = "modal._utils.function_utils" _CALL_RESULT_FUNCTION = "_process_result" +# Before raising anything, that function fetches an output Modal stored as a +# blob (any spawned call's output over 8 KiB) with this process's own +# requests; an error raised in them is not the call's outcome. +_OUTPUT_DOWNLOAD_MODULE = "modal._utils.blob_utils" # Signals to this process itself, which must never be taken for an outcome. OWN_PROCESS_SIGNALS: tuple[type[BaseException], ...] = ( asyncio.CancelledError, @@ -136,12 +141,25 @@ async def cancel(self, modal_call_id: str) -> None: def raised_from_call_result(error: BaseException) -> bool: - """Return whether Modal raised ``error`` from a finished call's result.""" - return any( - frame.f_globals.get("__name__") == _CALL_RESULT_MODULE - and frame.f_code.co_name == _CALL_RESULT_FUNCTION - for frame, _ in traceback.walk_tb(error.__traceback__) - ) + """Return whether Modal raised ``error`` as a finished call's outcome. + + That is so when Modal's result processing raised it, or re-raised the + worker's exception, but not when the error came from the output download + that processing makes first. + """ + frames = [frame for frame, _ in traceback.walk_tb(error.__traceback__)] + for position, frame in enumerate(frames): + if ( + frame.f_globals.get("__name__") == _CALL_RESULT_MODULE + and frame.f_code.co_name == _CALL_RESULT_FUNCTION + ): + # The frame it called, if any: a download, or the worker's own. + callee = frames[position + 1] if position + 1 < len(frames) else None + return ( + callee is None + or callee.f_globals.get("__name__") != _OUTPUT_DOWNLOAD_MODULE + ) + return False def _failed(code: RunErrorCode, message: str) -> CallFailed: diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index 1bb3ee01f..04c03117b 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -9,7 +9,8 @@ import grpclib.exceptions import modal import pytest -from modal._serialization import serialize +from modal._serialization import pickle_traceback, serialize +from modal._utils import blob_utils from modal._utils.function_utils import _process_result from modal_proto import api_pb2 from policyengine_simulation_entry import modal_calls @@ -385,6 +386,124 @@ def test_a_call_modal_timed_out_fails_as_a_timeout(monkeypatch) -> None: ) +BLOB_DOWNLOAD_URL = "https://blob.test/output" + + +class _BlobStub: + """Modal's API as its result processing uses it to fetch a stored output.""" + + def __init__(self, failure=None): + self.failure = failure + + async def BlobGet(self, request): # noqa: N802 # Modal's method name + assert request.blob_id == "bl-test" + if self.failure is not None: + raise self.failure + return api_pb2.BlobGetResponse(download_url=BLOB_DOWNLOAD_URL) + + +class _BlobStoredCall: + """A finished call whose output Modal stores as a blob. + + Modal does so for any spawned call's output over 8 KiB, so nearly every + real result is read this way: Modal's result processing first makes this + process's own requests for the blob, then decodes it. + """ + + def __init__(self, stub): + self.result = api_pb2.GenericResult( + status=api_pb2.GenericResult.GENERIC_STATUS_SUCCESS, + data_blob_id="bl-test", + ) + self.stub = stub + self.get = self + + async def aio(self, *, timeout): + assert timeout == 0 + return await _process_result( + self.result, api_pb2.DATA_FORMAT_PICKLE, self.stub, None + ) + + +def _inspect_blob_stored(monkeypatch, stub, download): + async def fake_download(download_url): + assert download_url == BLOB_DOWNLOAD_URL + if isinstance(download, BaseException): + raise download + return download + + monkeypatch.setattr(blob_utils, "_download_from_url", fake_download) + monkeypatch.setattr( + modal_calls.modal.FunctionCall, + "from_id", + lambda call_id: _BlobStoredCall(stub), + ) + return asyncio.run(ModalCallInspector().inspect("fc-test")) + + +def test_an_output_stored_as_a_blob_is_read(monkeypatch) -> None: + outcome = _inspect_blob_stored(monkeypatch, _BlobStub(), serialize({"budget": 1.0})) + + assert outcome == CallSucceeded({"budget": 1.0}) + + +@pytest.mark.parametrize( + ("blob_request_failure", "download_failure"), + [ + (modal.exception.ExecutionError("blob request failed"), None), + (grpclib.exceptions.GRPCError(grpclib.const.Status.UNAVAILABLE, "down"), None), + (modal.exception.ConnectionError("unreachable"), None), + (None, modal.exception.ExecutionError("Get from url failed with status 503")), + ], + ids=["blob-request-execution-error", "blob-request-grpc", "connection", "download"], +) +def test_a_failed_output_download_is_retried_later( + monkeypatch, blob_request_failure, download_failure +) -> None: + """Requests for a stored output are the entrypoint's own, not the worker's.""" + with pytest.raises(CallInspectionError): + _inspect_blob_stored( + monkeypatch, _BlobStub(blob_request_failure), download_failure + ) + + +def test_an_output_download_that_times_out_is_checked_again(monkeypatch) -> None: + outcome = _inspect_blob_stored(monkeypatch, _BlobStub(), TimeoutError()) + + assert outcome == CallRunning() + + +def _worker_exception_from_its_own_download(): + """Return what a worker raises when its own Modal blob download fails.""" + + async def worker(): + await blob_utils.blob_download( + "bl-test", _BlobStub(modal.exception.ConnectionError("worker's download")) + ) + + try: + asyncio.run(worker()) + except modal.exception.ConnectionError as error: + return error + raise AssertionError + + +def test_a_worker_exception_with_its_traceback_fails_the_run(monkeypatch) -> None: + """The worker's frames follow Modal's result processing, whatever they hold.""" + worker_exception = _worker_exception_from_its_own_download() + serialized_tb, tb_line_cache = pickle_traceback(worker_exception, "ta-test") + call = _FinishedCall(worker_exception) + call.result.serialized_tb = serialized_tb + call.result.tb_line_cache = tb_line_cache + monkeypatch.setattr(modal_calls.modal.FunctionCall, "from_id", lambda call_id: call) + + outcome = asyncio.run(ModalCallInspector().inspect("fc-test")) + + assert outcome == CallFailed( + RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised ConnectionError.") + ) + + @pytest.mark.parametrize( "signal", [asyncio.CancelledError(), KeyboardInterrupt(), SystemExit(0)], From 58aec6af8e029c06c45d65f4dccf4ccb4cced95d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:32:50 +0400 Subject: [PATCH 060/124] Keep a call whose record committed although its reply was lost From the review of the rebased 1.06: when recording an acknowledged call raised, the dispatcher assumed nothing had been written and cancelled the call. If the connection dropped after PostgreSQL committed the record, the runs were already running under the call, so a healthy call was cancelled and its runs later failed as if the worker had failed. Now the dispatcher first fails the runs if they are still dispatching, which only an uncommitted record leaves, and then cancels the call. Otherwise it reads whether the runs hold the call (RunTransitions.simulation_runs_hold_call and comparison_holds_call): if they do, the call started; if not (they were failed meanwhile as abandoned), the call is cancelled. When the database cannot answer either, the call is left running and the new outcome RECORD_UNKNOWN says so: the recording loop collects it if its record committed, and the runs are failed as abandoned otherwise. Co-Authored-By: Claude Opus 5.5 --- .../run_transitions.py | 37 +++++++- .../tests/test_run_transitions.py | 41 +++++++++ .../run_dispatch.py | 74 ++++++++++++---- .../tests/run_fakes.py | 14 +++ .../tests/test_run_dispatch.py | 86 ++++++++++++++++++- 5 files changed, 235 insertions(+), 17 deletions(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 8d442eeed..26f30ce4e 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -29,7 +29,7 @@ from enum import StrEnum from typing import TYPE_CHECKING -from sqlalchemy import and_, exists, or_, select, update +from sqlalchemy import and_, exists, func, or_, select, update from policyengine_simulation_persistence.run_store import utc_now from policyengine_simulation_persistence.runs import ( @@ -188,6 +188,26 @@ def record_simulation_dispatch_failure( }, ) + def simulation_runs_hold_call( + self, run_ids: Collection[UUID], modal_call_id: str + ) -> bool: + """Return whether every one of the runs recorded ``modal_call_id``. + + A dispatcher whose record raised reads this to learn whether the + record committed all the same, its reply having been lost. + """ + wanted = set(run_ids) + with self.engine.connect() as connection: + holding = connection.execute( + select(func.count()) + .select_from(simulation_runs) + .where( + simulation_runs.c.id.in_(wanted), + simulation_runs.c.modal_call_id == modal_call_id, + ) + ).scalar_one() + return bool(wanted) and holding == len(wanted) + def claim_due_simulation_calls( self, *, limit: int, lease: timedelta ) -> list[DueSimulationCall]: @@ -415,6 +435,21 @@ def record_comparison_dispatch_failure( is not None ) + def comparison_holds_call(self, report_run_id: UUID, modal_call_id: str) -> bool: + """Return whether the comparison step recorded ``modal_call_id``. + + As ``simulation_runs_hold_call``, for a comparison step. + """ + with self.engine.connect() as connection: + return connection.execute( + select( + exists().where( + report_runs.c.id == report_run_id, + report_runs.c.comparison_modal_call_id == modal_call_id, + ) + ) + ).scalar_one() + def claim_due_comparisons( self, *, limit: int, lease: timedelta ) -> list[ReportRunRecord]: diff --git a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py index d08655317..ad1da794a 100644 --- a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py @@ -112,6 +112,25 @@ def test_a_dispatched_run_records_its_call_and_first_check( ) +def test_runs_say_whether_they_recorded_a_call( + migrated_database, public_api_rows +) -> None: + """A dispatcher whose record raised reads whether it committed all the same.""" + stores = clocked_stores(migrated_database) + running = _running_run(stores, migrated_database, public_api_rows) + dispatching = _pending_run(stores, migrated_database, public_api_rows) + stores.transitions.claim_simulation_dispatch([dispatching.id]) + transitions = stores.transitions + + assert transitions.simulation_runs_hold_call([running.id], "fc-economy") + assert not transitions.simulation_runs_hold_call([running.id], "fc-other") + assert not transitions.simulation_runs_hold_call([dispatching.id], "fc-economy") + assert not transitions.simulation_runs_hold_call( + [running.id, dispatching.id], "fc-economy" + ) + assert not transitions.simulation_runs_hold_call([], "fc-economy") + + def test_a_refused_dispatch_fails_the_run_and_frees_the_setup( migrated_database, public_api_rows ) -> None: @@ -411,6 +430,28 @@ def test_a_comparison_failure_is_recorded(migrated_database, public_api_rows) -> assert failed.comparison_completed_at == stores.clock() +def test_a_comparison_says_whether_it_recorded_a_call( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + report_run, (baseline_run, reform_run) = _economy_report_run( + stores, migrated_database, public_api_rows + ) + _succeed(stores, baseline_run, "fc-baseline") + _succeed(stores, reform_run, "fc-reform") + transitions = stores.transitions + transitions.claim_comparison_dispatch(report_run.id) + + assert not transitions.comparison_holds_call(report_run.id, "fc-comparison") + + transitions.record_comparison_dispatched( + report_run.id, modal_call_id="fc-comparison", first_check_at=stores.clock() + ) + + assert transitions.comparison_holds_call(report_run.id, "fc-comparison") + assert not transitions.comparison_holds_call(report_run.id, "fc-other") + + def test_a_refused_comparison_dispatch_fails_the_step( migrated_database, public_api_rows ) -> None: diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index fd5053501..75c2b5d20 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -53,8 +53,13 @@ class DispatchOutcome(StrEnum): # Modal refused the call; the runs are failed. DISPATCH_FAILED = "dispatch_failed" # Modal acknowledged the call, but the runs had stopped dispatching before - # the call ID could be recorded. + # the call ID could be recorded, or recording it failed; the call is + # cancelled. NOT_RECORDED = "not_recorded" + # Recording the call raised and the database could not say whether the + # record committed; the call is left running. If it did, the recording + # loop collects the call; if not, the runs are failed as abandoned. + RECORD_UNKNOWN = "record_unknown" class DispatchTransitions(Protocol): @@ -80,6 +85,12 @@ def record_simulation_dispatch_failure( """Fail dispatching runs.""" ... + def simulation_runs_hold_call( + self, run_ids: Collection[UUID], modal_call_id: str + ) -> bool: + """Return whether every one of the runs recorded the call.""" + ... + def claim_comparison_dispatch(self, report_run_id: UUID) -> bool: """Move a pending comparison step to dispatching.""" ... @@ -100,14 +111,19 @@ def record_comparison_dispatch_failure( """Fail a dispatching comparison step.""" ... + def comparison_holds_call(self, report_run_id: UUID, modal_call_id: str) -> bool: + """Return whether the comparison step recorded the call.""" + ... + @dataclass(frozen=True) class _DispatchSteps: - """The three writes around one spawn, bound to the runs they change.""" + """The writes around one spawn, and a read, bound to the runs they change.""" claim: Callable[[], bool] record: Callable[[str, datetime], bool] fail: Callable[[RunError], bool] + holds_call: Callable[[str], bool] # "simulation_run" or "report_run_comparison": the log event names' start. event_prefix: str @@ -140,6 +156,9 @@ async def start_simulation_runs( fail=lambda error: self.transitions.record_simulation_dispatch_failure( runs, error ), + holds_call=lambda call_id: self.transitions.simulation_runs_hold_call( + runs, call_id + ), event_prefix="simulation_run", ), request, @@ -167,6 +186,9 @@ async def start_comparison( fail=lambda error: self.transitions.record_comparison_dispatch_failure( report_run_id, error ), + holds_call=lambda call_id: self.transitions.comparison_holds_call( + report_run_id, call_id + ), event_prefix="report_run_comparison", ), request, @@ -208,22 +230,19 @@ async def _record( A call whose runs never record it would run to its end with nobody collecting its output, and its runs would later be failed as abandoned; a resubmission would then start a second call. So such a - call is cancelled, and when the record failed, its runs are failed - at once with dispatch_failed if the database allows. + call is cancelled. """ context = {**subject, "modal_call_id": modal_call_id} try: recorded = await asyncio.to_thread( steps.record, modal_call_id, self.clock() + FIRST_CHECK_DELAY ) - except Exception as error: # the call is cancelled instead + except Exception as error: # settled below logger.exception( - _event_name(steps.event_prefix, "call_not_recorded"), + _event_name(steps.event_prefix, "call_record_failed"), extra={**context, "error_type": type(error).__name__}, ) - await self._cancel(modal_call_id, context, steps.event_prefix) - await self._fail_quietly(steps, context) - return DispatchOutcome.NOT_RECORDED + return await self._settle_failed_record(steps, modal_call_id, context) if not recorded: # The runs stopped dispatching while Modal acknowledged the call, # for example because the recording loop failed them as abandoned. @@ -246,19 +265,44 @@ async def _cancel( extra={**context, "error_type": type(error).__name__}, ) - async def _fail_quietly( - self, steps: _DispatchSteps, context: dict[str, object] - ) -> None: + async def _settle_failed_record( + self, + steps: _DispatchSteps, + modal_call_id: str, + context: dict[str, object], + ) -> DispatchOutcome: + """Settle a call whose record raised: it may have committed all the same. + + A record that committed but whose reply was lost leaves the runs + running under the call, which must keep running. Only an uncommitted + record leaves them dispatching, so they are failed if they still are, + and then the call is cancelled. Otherwise the runs are read: holding + the call, it started; not holding it (failed meanwhile as abandoned), + the call is cancelled. If the database cannot answer, the call is left + running, since cancelling it could stop a recorded call. + """ try: - await asyncio.to_thread( + failed = await asyncio.to_thread( steps.fail, RunError( RunErrorCode.DISPATCH_FAILED, "The Modal call could not be recorded, so it was cancelled.", ), ) - except Exception as error: # the abandoned-dispatch check fails the runs later + holds_call = not failed and await asyncio.to_thread( + steps.holds_call, modal_call_id + ) + except Exception as error: # the call is left running; see above logger.exception( - _event_name(steps.event_prefix, "dispatch_failure_not_recorded"), + _event_name(steps.event_prefix, "call_record_unknown"), extra={**context, "error_type": type(error).__name__}, ) + return DispatchOutcome.RECORD_UNKNOWN + if holds_call: + logger.info(_event_name(steps.event_prefix, "dispatched"), extra=context) + return DispatchOutcome.STARTED + logger.warning( + _event_name(steps.event_prefix, "call_not_recorded"), extra=context + ) + await self._cancel(modal_call_id, context, steps.event_prefix) + return DispatchOutcome.NOT_RECORDED diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index 799ed4f0e..232e0eb0c 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -233,6 +233,20 @@ def record_comparison_dispatch_failure( is not None ) + def simulation_runs_hold_call( + self, run_ids: Collection[UUID], modal_call_id: str + ) -> bool: + """Return whether every run recorded ``modal_call_id``.""" + with self.lock: + return all( + self.runs[run_id].modal_call_id == modal_call_id for run_id in run_ids + ) + + def comparison_holds_call(self, report_run_id: UUID, modal_call_id: str) -> bool: + """Return whether the comparison step recorded ``modal_call_id``.""" + with self.lock: + return self.comparisons[report_run_id].modal_call_id == modal_call_id + # --- Recording ------------------------------------------------------- def claim_due_simulation_calls( diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index 04c03117b..f9ef02a7b 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -162,7 +162,6 @@ def unreachable_database(*_arguments, **_keywords): raise ConnectionError(msg) transitions.record_simulation_dispatched = unreachable_database # type: ignore[method-assign] - transitions.record_simulation_dispatch_failure = unreachable_database # type: ignore[method-assign] outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) @@ -170,6 +169,91 @@ def unreachable_database(*_arguments, **_keywords): assert "simulation_run_call_not_cancelled" in caplog.text +def _commit_then_lose_acknowledgement(record): + """Return ``record`` changed to commit and then raise, as a lost reply does.""" + + def committed_without_acknowledgement(*arguments, **keywords): + assert record(*arguments, **keywords) + msg = "connection lost after the commit" + raise ConnectionError(msg) + + return committed_without_acknowledgement + + +@pytest.mark.asyncio +async def test_an_unacknowledged_record_keeps_its_call() -> None: + service, transitions, _ = _service() + run_id = transitions.add_run() + transitions.record_simulation_dispatched = _commit_then_lose_acknowledgement( # type: ignore[method-assign] + transitions.record_simulation_dispatched + ) + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.STARTED + assert service.dispatcher.cancelled == [] + run = execution(transitions, run_id) + assert run.status is RunStatus.RUNNING + assert run.modal_call_id == "fc-1" + + +@pytest.mark.asyncio +async def test_an_unacknowledged_comparison_record_keeps_its_call() -> None: + service, transitions, _ = _service() + comparison = transitions.add_comparison() + transitions.record_comparison_dispatched = _commit_then_lose_acknowledgement( # type: ignore[method-assign] + transitions.record_comparison_dispatched + ) + + outcome = await service.start_comparison(comparison, SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.STARTED + assert service.dispatcher.cancelled == [] + assert transitions.comparisons[comparison].status is RunStatus.RUNNING + + +@pytest.mark.asyncio +async def test_a_failed_record_of_runs_failed_meanwhile_cancels_the_call() -> None: + service, transitions, _ = _service() + run_id = transitions.add_run() + + def abandoned_meanwhile(*_arguments, **_keywords): + transitions.runs[run_id].status = RunStatus.FAILED + msg = "connection lost" + raise ConnectionError(msg) + + transitions.record_simulation_dispatched = abandoned_meanwhile # type: ignore[method-assign] + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.NOT_RECORDED + assert service.dispatcher.cancelled == ["fc-1"] + + +@pytest.mark.asyncio +async def test_a_call_whose_record_cannot_be_read_is_left_running(caplog) -> None: + """With the database unreachable, the record may have committed all the same.""" + service, transitions, _ = _service() + run_id = transitions.add_run() + + def unreachable_database(*_arguments, **_keywords): + msg = "connection lost" + raise ConnectionError(msg) + + transitions.record_simulation_dispatched = unreachable_database # type: ignore[method-assign] + transitions.record_simulation_dispatch_failure = unreachable_database # type: ignore[method-assign] + + outcome = await service.start_simulation_runs([run_id], SAMPLE_REQUEST) + + assert outcome is DispatchOutcome.RECORD_UNKNOWN + assert service.dispatcher.cancelled == [] + assert any( + record.message == "simulation_run_call_record_unknown" + and getattr(record, "modal_call_id", None) == "fc-1" + for record in caplog.records + ) + + def test_the_dispatcher_cancels_a_call_by_its_id(monkeypatch) -> None: cancelled = [] From 106518d8e0782e2e9776df928fda17d20b5e600d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:35:10 +0400 Subject: [PATCH 061/124] Pass on a shutdown that arrives while the recording loop stops From the review of the rebased 1.06: RunRecordingLoop.stop suppressed every CancelledError while waiting for the loop to end, including one cancelling the task that called stop, so a shutdown arriving then was swallowed. It now suppresses only the loop's own cancellation and re-raises the caller's. Two descriptions are corrected: the persistence README says simulations and reports (not every table listed above it) are definitions the runtime role cannot update, and the recording loop's docstring says a finished call without a recorder is checked again until Modal drops its output and the run fails as result_expired. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 4 +-- .../run_recording.py | 16 ++++++--- .../simulation_database.py | 7 ++-- .../tests/test_run_recording.py | 34 +++++++++++++++++++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 7e629f5e7..4a2ec59f0 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -17,8 +17,8 @@ writes them with DML only. | `simulation_runs` | One execution of a simulation for a catalog version and year: the region code and dataset row it resolved in that version (economy runs only), its status, Modal call ID, timings, error, trigger (`initial`, `manual` or `system`) and attempt number | | `report_runs` | One calculation of a report: a baseline and a reform simulation run of the same catalog version and year (no reform run for a single-simulation household report), the trigger, and, for an economy report, the comparison step's own status and Modal call | -Both are definitions: the runtime role may read and insert them but not update -or delete them. Rows migrated from the Public API's v1 database keep their v1 +`simulations` and `reports` are definitions: the runtime role may read and +insert them but not update or delete them. Rows migrated from the Public API's v1 database keep their v1 IDs in the unique `legacy_simulation_id` and `legacy_report_id` columns, and the stores' `create_migrated` methods return the existing row when the same v1 ID is migrated again. diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index c71df51a6..9dd41ab45 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -23,7 +23,6 @@ from __future__ import annotations import asyncio -import contextlib import logging from dataclasses import dataclass, field from datetime import timedelta @@ -514,10 +513,19 @@ def start(self) -> None: self._task = asyncio.create_task(self.run()) async def stop(self) -> None: - """Cancel the loop and wait for it to end.""" + """Cancel the loop and wait for it to end. + + A cancellation of the task calling this, such as a shutdown arriving + while the loop ends, is passed on rather than taken for the loop's. + """ if self._task is None: return self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): + try: await self._task - self._task = None + except asyncio.CancelledError: + caller = asyncio.current_task() + if caller is not None and caller.cancelling(): + raise + finally: + self._task = None diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index 43f595bbb..28bffdac0 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -48,9 +48,10 @@ async def read_revision(self) -> str | None: def recording_loop(self) -> RunRecordingLoop: """Return the loop that records Modal call outcomes on runs. - No kind of run has a result recorder yet, so a finished call would - stay running until a revision with a recorder records it; no route - creates runs yet. + No kind of run has a result recorder yet, so a finished call is + checked again until a revision with a recorder records it, or until + Modal no longer holds its output and the run fails as result_expired; + no route creates runs yet. """ return RunRecordingLoop( RunRecorder( diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index e41bec099..c1654dcfc 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -4,6 +4,7 @@ import asyncio import builtins +import contextlib from datetime import timedelta import pytest @@ -461,6 +462,39 @@ async def lifetime() -> None: assert scripted.calls > 0 +class _SlowToStopRecorder: + """A pass that takes a while to end once cancelled, as a closing write does.""" + + def __init__(self) -> None: + self.passing = asyncio.Event() + + async def record_due_outcomes(self) -> RecordingPass: + self.passing.set() + try: + await asyncio.sleep(60) + finally: + await asyncio.shield(asyncio.sleep(0.05)) + return RecordingPass() + + +def test_stopping_passes_on_a_cancellation_of_its_caller() -> None: + """Stopping the loop never swallows a shutdown of the task that stops it.""" + + async def shutdown() -> bool: + recorder = _SlowToStopRecorder() + loop = RunRecordingLoop(recorder, sleep=lambda _seconds: asyncio.sleep(0)) # type: ignore[arg-type] + loop.start() + await recorder.passing.wait() + stopper = asyncio.create_task(loop.stop()) + await asyncio.sleep(0.01) + stopper.cancel() + with contextlib.suppress(asyncio.CancelledError): + await stopper + return stopper.cancelled() + + assert asyncio.run(shutdown()) + + class _TrackedLoop: def __init__(self) -> None: self.started = False From 7eddb15f4ded70d60227bfcd96c3b8038d39a96c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:57:37 +0400 Subject: [PATCH 062/124] Keep backing off for a call checked more than 46 times From the fifth review of 1.06: next_check_delay doubled the first wait before capping it at a minute, so from a call's 47th check (about 42 minutes after dispatch) the product overflowed timedelta and raised. The check was then logged as failed every minute, which would hide real Modal or database errors, and the claim's lease replaced the backoff. The exponent is now capped first; the test covers the 47th and the 10,000th check. Co-Authored-By: Claude Opus 5.5 --- .../policyengine_simulation_entry/run_recording.py | 8 +++++++- .../tests/test_run_recording.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 9dd41ab45..caeac59fe 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -78,9 +78,15 @@ def _event_name(prefix: str, outcome: str) -> str: IDLE_PASS_INTERVAL_SECONDS = 5.0 +# Past this many doublings the wait is LONGEST_BACKOFF anyway; capping the +# exponent keeps the product within timedelta's range for a long call. +_MOST_DOUBLINGS = 16 + + def next_check_delay(check_count: int) -> timedelta: """Return the wait before the next check of a call checked ``check_count`` times.""" - return min(FIRST_BACKOFF * 2 ** max(check_count - 1, 0), LONGEST_BACKOFF) + doublings = min(max(check_count - 1, 0), _MOST_DOUBLINGS) + return min(FIRST_BACKOFF * 2**doublings, LONGEST_BACKOFF) class SimulationResultRecorder(Protocol): diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index c1654dcfc..67c1cda8e 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -79,7 +79,17 @@ def _run(recorder) -> RecordingPass: @pytest.mark.parametrize( ("check_count", "seconds"), - [(0, 2), (1, 2), (2, 4), (3, 8), (5, 32), (6, 60), (40, 60)], + [ + (0, 2), + (1, 2), + (2, 4), + (3, 8), + (5, 32), + (6, 60), + (40, 60), + (47, 60), + (10_000, 60), + ], ) def test_checks_back_off_up_to_a_minute(check_count, seconds) -> None: assert next_check_delay(check_count) == timedelta(seconds=seconds) From a1fee05005a859b29b3779af680ba3d238b16118 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:58:42 +0400 Subject: [PATCH 063/124] Store a failed dispatch's error without claiming the call was cancelled From the fifth review of 1.06: when recording an acknowledged call failed, the runs were failed with "The Modal call could not be recorded, so it was cancelled." before the cancel was tried, so the message was false when the cancel then failed. It now says only that the call could not be recorded; whether the cancel worked is logged (call_not_cancelled on failure). Co-Authored-By: Claude Opus 5.5 --- .../src/policyengine_simulation_persistence/run_transitions.py | 2 +- .../src/policyengine_simulation_entry/run_dispatch.py | 2 +- .../policyengine-simulation-entry/tests/test_run_dispatch.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 26f30ce4e..af74e5bce 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -58,7 +58,7 @@ class RunErrorCode(StrEnum): """Why a run or comparison step failed.""" # Modal refused the call or never acknowledged it, or the acknowledged - # call could not be recorded and was cancelled. + # call could not be recorded (the entrypoint then tries to cancel it). DISPATCH_FAILED = "dispatch_failed" # The instance stopped between claiming the dispatch and recording the call. DISPATCH_ABANDONED = "dispatch_abandoned" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index 75c2b5d20..b7730cdeb 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -286,7 +286,7 @@ async def _settle_failed_record( steps.fail, RunError( RunErrorCode.DISPATCH_FAILED, - "The Modal call could not be recorded, so it was cancelled.", + "The Modal call could not be recorded.", ), ) holds_call = not failed and await asyncio.to_thread( diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index f9ef02a7b..0309ace38 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -144,7 +144,7 @@ def unreachable_database(*_arguments, **_keywords): run = execution(transitions, run_id) assert run.status is RunStatus.FAILED assert run.error.code is RunErrorCode.DISPATCH_FAILED - assert "could not be recorded" in run.error.message + assert run.error.message == "The Modal call could not be recorded." assert any( getattr(record, "modal_call_id", None) == "fc-1" for record in caplog.records ) From d64da40e18675fec6130b966f53787b9549e34ab Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:54:44 +0400 Subject: [PATCH 064/124] Stop recording outcomes in a background loop in each instance Decided 2026-10-27: Modal reports when a call ends, and a scheduled Cloud Run job is the safety check. The background loop is removed: Cloud Run keeps a minimum instance for every revision with a traffic tag, so a rejected or rolled-back production revision kept running it and recording production outcomes with its own code. The app's lifespan no longer starts or stops a loop, SimulationDatabase no longer builds one, and the deploy no longer passes --no-cpu-throttling: the web service records outcomes only within requests. RunRecorder, which runs one pass of due checks, stays for the safety check. Every deploy still mounts sim's own Modal token. Co-Authored-By: Claude Opus 5.5 --- .../deploy-cloud-run-simulation-entry.sh | 7 +- .../README.md | 2 +- .../run_transitions.py | 6 +- .../runs.py | 2 +- .../tests/test_recording_load.py | 4 +- .../DEPLOYMENT.md | 25 ++- .../src/policyengine_simulation_entry/app.py | 9 -- .../entry_runtime.py | 5 - .../run_dispatch.py | 8 +- .../run_recording.py | 90 ++--------- .../simulation_database.py | 24 --- ...ment.py => test_modal_token_deployment.py} | 14 +- .../tests/test_run_recording.py | 146 +----------------- .../tests/test_simulation_database.py | 3 - 14 files changed, 46 insertions(+), 299 deletions(-) rename projects/policyengine-simulation-entry/tests/{test_run_recording_deployment.py => test_modal_token_deployment.py} (84%) diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 05836e82f..36b1899ff 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -145,9 +145,9 @@ add_runtime_secret OLD_GATEWAY_AUTH_CLIENT_SECRET \ # The runtime role's URL for the entrypoint's own simulation schema. The # migration job, which uses a separate migrator URL, runs before this deploy. add_runtime_secret SIMULATION_DATABASE_URL "${SIMULATION_DATABASE_URL_SECRET_NAME}" -# sim's own Modal token: the entrypoint starts runs' Modal calls and its -# background loop asks Modal about them, on every deploy. Until the Stage 12 -# comparison path is removed, its Modal calls use the same token. +# sim's own Modal token, on every deploy: the entrypoint starts runs' Modal +# calls and asks Modal about a call when Modal reports that it ended. Until +# the Stage 12 comparison path is removed, its Modal calls use the same token. add_runtime_secret MODAL_TOKEN_ID "${MODAL_TOKEN_ID_SECRET_NAME}" add_runtime_secret MODAL_TOKEN_SECRET "${MODAL_TOKEN_SECRET_SECRET_NAME}" @@ -214,7 +214,6 @@ gcloud run deploy "${ENTRYPOINT_SERVICE}" \ --env-vars-file "${runtime_environment_file}" \ --set-secrets "${runtime_secrets_csv}" \ --cpu 1 \ - --no-cpu-throttling \ --memory 512Mi \ --concurrency 80 \ --timeout 60 \ diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 4a2ec59f0..85b878aae 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -55,7 +55,7 @@ not delete them. `RunTransitions` makes every status change one conditional update on the expected status, so a second writer changes nothing: only the caller that moves runs from `pending` to `dispatching` starts their Modal call, the runs -one call computes change together, the recording loop claims a due call by +one call computes change together, a recording pass claims a due call by moving its next check time, results are written in the transaction that marks a run succeeded, and dispatches left `dispatching` (and comparison steps whose simulation run failed) are failed so the work can be submitted diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index af74e5bce..0d0a2e5aa 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -9,9 +9,9 @@ starts its Modal call, and it then records the call ID (``running``); - the runs that share one Modal call, such as a household report's baseline and reform, change status together, all or none; -- the recording loop claims a running call's next check by moving its - ``next_check_at`` forward, so instances do not check the same call at once, - and only one of them records its outcome; +- a recording pass claims a running call's next check by moving its + ``next_check_at`` forward, so two passes do not check the same call at + once, and only one of them records its outcome; - a call's results are written in the same transaction that marks its runs ``succeeded``, so a run never succeeds without its results; - a run left in ``dispatching`` (the instance stopped between claiming it and diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py index 4f886d3e1..3ad05ea99 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -247,7 +247,7 @@ def _execution_columns(prefix: str, *, required: bool) -> list[Column[Any]]: simulation_runs.c.modal_call_id, postgresql_where=simulation_runs.c.modal_call_id.is_not(None), ) -# The recording loop reads only running runs that are due a check, and +# A recording pass reads only running runs that are due a check, and # dispatches that stopped. Index( "ix_simulation_runs_due_checks", diff --git a/libs/policyengine-simulation-persistence/tests/test_recording_load.py b/libs/policyengine-simulation-persistence/tests/test_recording_load.py index efdec7d0b..048151da5 100644 --- a/libs/policyengine-simulation-persistence/tests/test_recording_load.py +++ b/libs/policyengine-simulation-persistence/tests/test_recording_load.py @@ -1,8 +1,8 @@ -"""A load check of the recording loop's conditional writes on PostgreSQL. +"""A load check of the recording passes' conditional writes on PostgreSQL. Several simulated Simulation Entrypoint instances, each on its own engine, repeatedly fail stalled dispatches, claim due calls and record an outcome for -each, as the recording loop does, until every run has finished. A third of +each, as recording passes do, until every run has finished. A third of the calls are household pairs (two runs under one call). Every call must be recorded once, its results written once and all of its runs finished once, and every stalled dispatch failed once, whatever the interleaving. The diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 901063056..dbe4d5461 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -58,22 +58,15 @@ The ownership rules and roles are described in ## Recording run outcomes -Each Simulation Entrypoint instance runs a background loop that asks Modal -about the runs' calls and records their outcomes in the `simulation` schema -(`policyengine_simulation_entry.run_recording`). Two deploy settings serve it: - -- `gcloud run deploy --no-cpu-throttling` keeps the instance's CPU allocated - outside requests, so the loop runs between them. Production keeps at least - one instance running (`entrypoint_min_instances: 1`); staging scales to - zero, so it records outcomes only while an instance runs, and any request - starts one. -- Every deploy mounts sim's own Modal token as `MODAL_TOKEN_ID` and - `MODAL_TOKEN_SECRET`, whatever the Stage 12 setting: the deploy step - resolves the Secret Manager secrets named by the `beta` and `prod` - environment variables `MODAL_TOKEN_ID_SECRET_NAME` and - `MODAL_TOKEN_SECRET_SECRET_NAME` to their newest enabled versions. The - runtime service account must be able to read both secrets. The Stage 12 - comparison path's Modal calls use the same token until it is removed. +The Simulation Entrypoint records a run's outcome in the `simulation` schema +within a request; nothing runs between requests, so Cloud Run keeps no CPU +allocated outside them. Every deploy mounts sim's own Modal token as +`MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, whatever the Stage 12 setting: the +deploy step resolves the Secret Manager secrets named by the `beta` and `prod` +environment variables `MODAL_TOKEN_ID_SECRET_NAME` and +`MODAL_TOKEN_SECRET_SECRET_NAME` to their newest enabled versions. The runtime +service account must be able to read both secrets. The Stage 12 comparison +path's Modal calls use the same token until it is removed. ## Candidate qualification diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index 9ef858f5c..d862ebcd1 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -572,18 +572,9 @@ def create_app( async def lifespan(_: FastAPI) -> AsyncIterator[None]: runtime_settings.validate() await runtime_backend.start() - # Each instance records the outcomes of due Modal calls in the - # background; Cloud Run keeps its CPU allocated between requests. - recording_loop = ( - None if runtime_database is None else runtime_database.recording_loop() - ) - if recording_loop is not None: - recording_loop.start() try: yield finally: - if recording_loop is not None: - await recording_loop.stop() await runtime_backend.close() if runtime_database is not None: runtime_database.dispose() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py index 1353a6cfc..4898f758f 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/entry_runtime.py @@ -41,7 +41,6 @@ ) from policyengine_simulation_entry.config import Settings - from policyengine_simulation_entry.run_recording import RunRecordingLoop # Moved out of app.py; this code still logs under that module's name. logger = logging.getLogger("policyengine_simulation_entry.app") @@ -221,10 +220,6 @@ async def read_revision(self) -> str | None: """Return the applied migration revision, or None before the first one.""" ... - def recording_loop(self) -> RunRecordingLoop | None: - """Return the loop that records Modal call outcomes on runs, if any.""" - ... - def dispose(self) -> None: """Close every pooled connection.""" ... diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index b7730cdeb..7bb3524ea 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -8,8 +8,8 @@ 3. record the call ID, moving the runs to ``running`` with their first check due shortly; or, when Modal refused the call, fail them. -If the instance stops between 1 and 3, the runs stay ``dispatching`` until the -recording loop fails them as abandoned. No route starts calls yet. +If the instance stops between 1 and 3, the runs stay ``dispatching`` until a +recording pass fails them as abandoned. No route starts calls yet. """ from __future__ import annotations @@ -39,7 +39,7 @@ def _event_name(prefix: str, outcome: str) -> str: return f"{prefix}_{outcome}" -# The first check of a new call; the recording loop backs off from there. +# The first check of a new call; a recording pass backs off from there. FIRST_CHECK_DELAY = timedelta(seconds=2) @@ -245,7 +245,7 @@ async def _record( return await self._settle_failed_record(steps, modal_call_id, context) if not recorded: # The runs stopped dispatching while Modal acknowledged the call, - # for example because the recording loop failed them as abandoned. + # for example because a recording pass failed them as abandoned. logger.warning( _event_name(steps.event_prefix, "call_not_recorded"), extra=context ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index caeac59fe..be934c947 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -1,23 +1,22 @@ -"""The background loop that records each Modal call's outcome on its runs. +"""Record each Modal call's outcome on its runs, one pass of due checks at a time. -Each Simulation Entrypoint instance runs one loop. On every pass it: +A pass: 1. fails runs and comparison steps left ``dispatching`` for ten minutes, so they can be submitted again, and pending comparison steps whose baseline or reform run failed, since they can never start; 2. claims the running calls that are due a check, by moving their next check - one lease ahead, so instances do not check the same call at once; + one lease ahead, so two passes do not check the same call at once; 3. asks Modal about each claimed call without waiting, and records what it found with conditional writes: a finished call's result is validated and its result rows are written in the transaction that marks its runs ``succeeded``; a failed or timed-out call is marked ``failed`` with its error; a call still running, or finished but of a kind this revision has - no recorder for, gets a later check, backing off. + no recorder for, gets a later check. Pre-emption is never a failure: Modal re-runs a pre-empted call under the same call ID, so it reads as still running. No caller polls, and Modal holds -no database credentials: this loop writes every outcome. Cloud Run keeps the -instance's CPU allocated between requests, so the loop runs then too. +no database credentials: sim writes every outcome. """ from __future__ import annotations @@ -38,7 +37,7 @@ ) if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Mapping + from collections.abc import Callable, Mapping from datetime import datetime from uuid import UUID @@ -72,10 +71,8 @@ def _event_name(prefix: str, outcome: str) -> str: CLAIM_BATCH_SIZE = 50 # Each check writes through the runtime engine's small pool, which requests # share (policyengine_simulation_persistence.engine: 2 connections plus 3 -# overflow), so the loop uses at most three at once. +# overflow), so a pass uses at most three at once. INSPECTION_CONCURRENCY = 3 -BUSY_PASS_INTERVAL_SECONDS = 1.0 -IDLE_PASS_INTERVAL_SECONDS = 5.0 # Past this many doublings the wait is LONGEST_BACKOFF anyway; capping the @@ -137,7 +134,7 @@ class ResultRecorders: class RecordingTransitions(Protocol): - """The ``RunTransitions`` writes the loop needs.""" + """The ``RunTransitions`` writes a recording pass needs.""" def claim_due_simulation_calls( self, *, limit: int, lease: timedelta @@ -423,8 +420,7 @@ async def _record( return steps = handlers.result_steps if steps is None: - # During a deploy an older revision runs beside the new one, and - # with CPU always allocated its loop keeps checking calls. A + # During a deploy an older revision runs beside the new one. A # revision without a recorder for this kind leaves the call for # one that has it; Modal's output retention bounds the wait. next_check_at = self.clock() + next_check_delay(handlers.check_count) @@ -467,71 +463,3 @@ async def _fail( _event_name(handlers.event_prefix, "failed"), extra={**handlers.subject, "error_code": error.code.value}, ) - - -class RunRecordingLoop: - """Run recording passes in the background until stopped. - - A pass that raises is logged and the loop goes on: recording problems - never stop the instance or fail a request. - """ - - def __init__( - self, - recorder: RunRecorder, - *, - sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, - ) -> None: - """Run passes of ``recorder``, waiting between them with ``sleep``.""" - self.recorder = recorder - self._sleep = sleep - self._task: asyncio.Task[None] | None = None - - async def run(self) -> None: - """Record outcomes until cancelled; wait less while there is work.""" - while True: - await self._sleep(IDLE_PASS_INTERVAL_SECONDS) - recording = await self._pass() - while recording is not None and recording.found_work: - await self._sleep(BUSY_PASS_INTERVAL_SECONDS) - recording = await self._pass() - - async def _pass(self) -> RecordingPass | None: - """Run one pass; log and return None if it raised anything. - - Only a signal to this process, such as the cancellation that stops - the loop at shutdown, passes through. - """ - try: - return await self.recorder.record_due_outcomes() - except BaseException as error: - if isinstance(error, OWN_PROCESS_SIGNALS): - raise - logger.exception( - "simulation_run_recording_pass_failed", - extra={"error_type": type(error).__name__}, - ) - return None - - def start(self) -> None: - """Start the loop as a background task of the running event loop.""" - if self._task is None: - self._task = asyncio.create_task(self.run()) - - async def stop(self) -> None: - """Cancel the loop and wait for it to end. - - A cancellation of the task calling this, such as a shutdown arriving - while the loop ends, is passed on rather than taken for the loop's. - """ - if self._task is None: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - caller = asyncio.current_task() - if caller is not None and caller.cancelling(): - raise - finally: - self._task = None diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index 28bffdac0..e0ca4f1bd 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -6,18 +6,10 @@ from typing import TYPE_CHECKING from policyengine_simulation_persistence import ( - RunTransitions, create_simulation_engine, read_schema_revision, ) -from policyengine_simulation_entry.modal_calls import ModalCallInspector -from policyengine_simulation_entry.run_recording import ( - ResultRecorders, - RunRecorder, - RunRecordingLoop, -) - if TYPE_CHECKING: from sqlalchemy import Engine @@ -45,22 +37,6 @@ async def read_revision(self) -> str | None: """Return the applied migration revision, or None before the first one.""" return await asyncio.to_thread(self._read_revision) - def recording_loop(self) -> RunRecordingLoop: - """Return the loop that records Modal call outcomes on runs. - - No kind of run has a result recorder yet, so a finished call is - checked again until a revision with a recorder records it, or until - Modal no longer holds its output and the run fails as result_expired; - no route creates runs yet. - """ - return RunRecordingLoop( - RunRecorder( - transitions=RunTransitions(self.engine), - inspector=ModalCallInspector(), - recorders=ResultRecorders(), - ) - ) - def dispose(self) -> None: """Close every pooled connection.""" self.engine.dispose() diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py b/projects/policyengine-simulation-entry/tests/test_modal_token_deployment.py similarity index 84% rename from projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py rename to projects/policyengine-simulation-entry/tests/test_modal_token_deployment.py index ba08a2db6..24563081a 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_modal_token_deployment.py @@ -1,8 +1,9 @@ -"""Deployment settings the background recording loop needs. +"""The entrypoint's Modal token, and no CPU kept outside requests. -The loop runs between requests, so Cloud Run must keep the instance's CPU -allocated outside them; and it asks Modal about calls, so every deploy mounts -sim's own Modal token, whether or not it also deploys the Stage 12 path. +The entrypoint starts runs' Modal calls and asks Modal about a call when +Modal reports that it ended, so every deploy mounts sim's own Modal token, +whether or not it also deploys the Stage 12 path. It records outcomes only +within requests, so Cloud Run keeps no CPU allocated between them. """ from __future__ import annotations @@ -38,14 +39,13 @@ def _run_deploy(tmp_path, **overrides): @pytest.mark.parametrize("deploy_stage12", ["true", "false"]) -def test_every_deploy_keeps_cpu_allocated_between_requests( +def test_no_deploy_keeps_cpu_allocated_outside_requests( tmp_path, deploy_stage12 ) -> None: result, arguments = _run_deploy(tmp_path, DEPLOY_STAGE12_V2=deploy_stage12) assert result.returncode == 0, result.stderr - assert "--no-cpu-throttling" in arguments - assert "--cpu-throttling" not in arguments + assert "--no-cpu-throttling" not in arguments @pytest.mark.parametrize("deploy_stage12", ["true", "false"]) diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index 67c1cda8e..5f9dde2aa 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -1,10 +1,9 @@ -"""The background loop that records Modal call outcomes on runs.""" +"""Recording each Modal call's outcome on its runs, one pass at a time.""" from __future__ import annotations import asyncio import builtins -import contextlib from datetime import timedelta import pytest @@ -18,13 +17,10 @@ CallSucceeded, ) from policyengine_simulation_entry.run_recording import ( - BUSY_PASS_INTERVAL_SECONDS, CHECK_LEASE, - IDLE_PASS_INTERVAL_SECONDS, RecordingPass, ResultRecorders, RunRecorder, - RunRecordingLoop, next_check_delay, ) from policyengine_simulation_persistence import ( @@ -398,152 +394,24 @@ def interrupted(modal_call_id, *, next_check_at): _run(recorder) -class _ScriptedRecorder: - """Return scripted passes, or raise, and count how often it ran.""" - - def __init__(self, passes) -> None: - self.passes = list(passes) - self.calls = 0 - - async def record_due_outcomes(self) -> RecordingPass: - self.calls += 1 - outcome = self.passes.pop(0) if self.passes else RecordingPass() - if isinstance(outcome, BaseException): - raise outcome - return outcome - - -def test_the_loop_waits_less_while_there_is_work_and_survives_failures() -> None: - scripted = _ScriptedRecorder( - [ - RecordingPass(checked=3), - RecordingPass(), - RuntimeError("database down"), - builtins.BaseExceptionGroup("pass", [_NotAnException()]), - ] - ) - waits = [] - - async def sleep(seconds: float) -> None: - waits.append(seconds) - if len(waits) == 6: - raise asyncio.CancelledError - - loop = RunRecordingLoop(scripted, sleep=sleep) # type: ignore[arg-type] - - with pytest.raises(asyncio.CancelledError): - asyncio.run(loop.run()) - - assert waits == [ - IDLE_PASS_INTERVAL_SECONDS, - BUSY_PASS_INTERVAL_SECONDS, - IDLE_PASS_INTERVAL_SECONDS, - IDLE_PASS_INTERVAL_SECONDS, - IDLE_PASS_INTERVAL_SECONDS, - IDLE_PASS_INTERVAL_SECONDS, - ] - assert scripted.calls == 5 - - -def test_a_signal_to_this_process_stops_the_loop() -> None: - scripted = _ScriptedRecorder([KeyboardInterrupt()]) - - async def sleep(_seconds: float) -> None: - return None - - loop = RunRecordingLoop(scripted, sleep=sleep) # type: ignore[arg-type] - - with pytest.raises(KeyboardInterrupt): - asyncio.run(loop.run()) - - -def test_the_loop_starts_and_stops_as_a_background_task() -> None: - scripted = _ScriptedRecorder([]) - - async def lifetime() -> None: - loop = RunRecordingLoop(scripted, sleep=lambda _seconds: asyncio.sleep(0)) # type: ignore[arg-type] - loop.start() - await asyncio.sleep(0.01) - await loop.stop() - await loop.stop() - - asyncio.run(lifetime()) - - assert scripted.calls > 0 - - -class _SlowToStopRecorder: - """A pass that takes a while to end once cancelled, as a closing write does.""" - - def __init__(self) -> None: - self.passing = asyncio.Event() - - async def record_due_outcomes(self) -> RecordingPass: - self.passing.set() - try: - await asyncio.sleep(60) - finally: - await asyncio.shield(asyncio.sleep(0.05)) - return RecordingPass() - - -def test_stopping_passes_on_a_cancellation_of_its_caller() -> None: - """Stopping the loop never swallows a shutdown of the task that stops it.""" - - async def shutdown() -> bool: - recorder = _SlowToStopRecorder() - loop = RunRecordingLoop(recorder, sleep=lambda _seconds: asyncio.sleep(0)) # type: ignore[arg-type] - loop.start() - await recorder.passing.wait() - stopper = asyncio.create_task(loop.stop()) - await asyncio.sleep(0.01) - stopper.cancel() - with contextlib.suppress(asyncio.CancelledError): - await stopper - return stopper.cancelled() - - assert asyncio.run(shutdown()) - - -class _TrackedLoop: - def __init__(self) -> None: - self.started = False - self.stopped = False - - def start(self) -> None: - self.started = True - - async def stop(self) -> None: - self.stopped = True - - -class _DatabaseWithLoop: - def __init__(self) -> None: - self.loop = _TrackedLoop() - +class _DatabaseThatOnlyReadsItsRevision: async def read_revision(self): return HEAD_REVISION - def recording_loop(self): - return self.loop - def dispose(self) -> None: return None -def test_each_instance_runs_the_loop_for_its_lifetime() -> None: - database = _DatabaseWithLoop() +def test_an_instance_runs_nothing_in_the_background() -> None: + """Outcomes are recorded when Modal reports them, within a request.""" app = create_app( settings=make_settings( simulation_database_url="postgresql://runtime:secret@db.example/postgres" ), backend=FakeBackend(), auth_dependency=lambda: None, - simulation_database=database, + simulation_database=_DatabaseThatOnlyReadsItsRevision(), ) - with TestClient(app): - assert database.loop.started is True - assert database.loop.stopped is False - - assert database.loop.stopped is True + with TestClient(app) as client: + assert client.get("/health").status_code == 200 diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database.py b/projects/policyengine-simulation-entry/tests/test_simulation_database.py index 6a7e55547..95b1defae 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database.py @@ -33,9 +33,6 @@ async def read_revision(self): raise self.failure return self.revision - def recording_loop(self): - return None - def dispose(self): self.disposed = True From 785619764ee898e5d3c1cdb5eabec16f27ebce92 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:56:51 +0400 Subject: [PATCH 065/124] Define the report a Modal call sends sim when it ends A new contract module, call_reports, holds what the reporting route and its sender share: CallEndedReport (a Modal call ID with either the one or two simulation runs it computes or the report run whose comparison it computes, and nothing else), the route's path, the name of the Modal function that sends reports, and CallReportAnswer with the statuses the route answers. The statuses in RETRY_LATER_STATUSES (not dispatched yet, still running, Modal or the database unreachable, no result recorder, not configured) tell the sender to report again after a wait. Co-Authored-By: Claude Opus 5.5 --- .../call_reports.py | 119 ++++++++++++++++++ .../tests/test_call_reports.py | 99 +++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py create mode 100644 libs/policyengine-simulation-contract/tests/test_call_reports.py diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py new file mode 100644 index 000000000..823457b70 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py @@ -0,0 +1,119 @@ +"""The report a Modal call sends the Simulation Entrypoint when it ends. + +The last step of each top-level v2 Modal function (the economy coordinator +and the household functions) reports that its call ended: it starts the small +Modal function ``CALL_REPORT_FUNCTION_NAME``, which posts a +``CallEndedReport`` to the entrypoint's ``CALL_ENDED_PATH`` with the Modal +identity token of its container as a bearer token. The report carries the +call ID and the run IDs only, never a result: the entrypoint checks that the +request comes from that Modal function, asks Modal for the call's outcome +itself, and records it. + +The entrypoint answers with a ``CallReportAnswer``. While it cannot record +the outcome yet (the call has not finished, its ID is not stored yet, Modal +or the database cannot be reached), it answers 503 with a status in +``RETRY_LATER_STATUSES``, and the sender reports again after a wait. A +repeated report changes nothing. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +# The entrypoint's route for call-ended reports. It is not in the public +# OpenAPI document. +CALL_ENDED_PATH = "/internal/modal-calls/ended" +# The Modal function that sends reports; the entrypoint accepts identity +# tokens of this function only. +CALL_REPORT_FUNCTION_NAME = "report_call_ended" +# Modal sets this variable to the container's identity token in every +# function container. +MODAL_IDENTITY_ENVIRONMENT_VARIABLE = "MODAL_IDENTITY_TOKEN" +# A Modal function call ID, as Modal prints it. +MODAL_CALL_ID_PATTERN = r"^fc-[A-Za-z0-9]{1,64}$" +# A call computes one simulation run, or a household report's baseline and +# reform runs together. +MOST_SIMULATION_RUNS_PER_CALL = 2 + + +class CallEndedReport(BaseModel): + """A Modal call's report that it ended: its ID and the runs it computes. + + Exactly one of ``simulation_run_ids`` (a simulation call: one run, or a + household report's two) and ``report_run_id`` (an economy comparison + step's call) is set. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + modal_call_id: str = Field(pattern=MODAL_CALL_ID_PATTERN) + simulation_run_ids: tuple[UUID, ...] | None = Field( + default=None, min_length=1, max_length=MOST_SIMULATION_RUNS_PER_CALL + ) + report_run_id: UUID | None = None + + @model_validator(mode="after") + def _names_one_kind_of_run(self) -> Self: + if (self.simulation_run_ids is None) == (self.report_run_id is None): + msg = "name either simulation_run_ids or report_run_id" + raise ValueError(msg) + if self.simulation_run_ids is not None and len( + set(self.simulation_run_ids) + ) != len(self.simulation_run_ids): + msg = "simulation_run_ids names a run twice" + raise ValueError(msg) + return self + + +class CallReportStatus(StrEnum): + """What the entrypoint did with a report.""" + + # The call's outcome is now recorded on its runs. + RECORDED = "recorded" + # The runs already hold the call's outcome; nothing changed. + ALREADY_RECORDED = "already_recorded" + # The runs are still being dispatched and do not hold the call ID yet. + NOT_DISPATCHED_YET = "not_dispatched_yet" + # Modal holds no output for the call yet. + STILL_RUNNING = "still_running" + # Modal could not be asked about the call. + MODAL_UNREACHABLE = "modal_unreachable" + # The serving revision cannot record this kind of result yet. + NO_RESULT_RECORDER = "no_result_recorder" + # The database could not be read or written. + NOT_WRITTEN = "not_written" + # The entrypoint is not configured to accept reports. + NOT_CONFIGURED = "not_configured" + # No run, or not every run, the report names exists. + UNKNOWN_RUNS = "unknown_runs" + # The runs hold another Modal call. + OTHER_CALL = "other_call" + + +RETRY_LATER_STATUSES = frozenset( + { + CallReportStatus.NOT_DISPATCHED_YET, + CallReportStatus.STILL_RUNNING, + CallReportStatus.MODAL_UNREACHABLE, + CallReportStatus.NO_RESULT_RECORDER, + CallReportStatus.NOT_WRITTEN, + CallReportStatus.NOT_CONFIGURED, + } +) + + +class CallReportAnswer(BaseModel): + """The entrypoint's answer to a report.""" + + model_config = ConfigDict(frozen=True) + + status: CallReportStatus + + @property + def retry_later(self) -> bool: + """Whether the sender should report again after a wait.""" + return self.status in RETRY_LATER_STATUSES diff --git a/libs/policyengine-simulation-contract/tests/test_call_reports.py b/libs/policyengine-simulation-contract/tests/test_call_reports.py new file mode 100644 index 000000000..8ccce497b --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_call_reports.py @@ -0,0 +1,99 @@ +"""The report a Modal function's call sends sim when it ends, and sim's answer.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_contract.call_reports import ( + RETRY_LATER_STATUSES, + CallEndedReport, + CallReportAnswer, + CallReportStatus, +) +from pydantic import ValidationError + +CALL_ID = "fc-01JB3W7X9Z8Q2R5T6Y4U1I0O9P" + + +def test_a_report_names_the_simulation_runs_its_call_computes() -> None: + baseline, reform = uuid4(), uuid4() + + report = CallEndedReport( + modal_call_id=CALL_ID, simulation_run_ids=(baseline, reform) + ) + + assert report.simulation_run_ids == (baseline, reform) + assert report.report_run_id is None + + +def test_a_report_names_the_report_run_whose_comparison_it_computes() -> None: + report_run_id = uuid4() + + report = CallEndedReport(modal_call_id=CALL_ID, report_run_id=report_run_id) + + assert report.report_run_id == report_run_id + assert report.simulation_run_ids is None + + +def test_a_report_round_trips_as_json() -> None: + report = CallEndedReport(modal_call_id=CALL_ID, simulation_run_ids=(uuid4(),)) + + assert CallEndedReport.model_validate_json(report.model_dump_json()) == report + + +@pytest.mark.parametrize( + "fields", + [ + {}, + {"simulation_run_ids": (uuid4(),), "report_run_id": uuid4()}, + {"simulation_run_ids": ()}, + {"simulation_run_ids": (uuid4(), uuid4(), uuid4())}, + ], + ids=["neither", "both", "no-runs", "three-runs"], +) +def test_a_report_names_either_one_or_two_simulation_runs_or_one_report_run( + fields, +) -> None: + with pytest.raises(ValidationError): + CallEndedReport(modal_call_id=CALL_ID, **fields) + + +def test_a_report_names_each_simulation_run_once() -> None: + run_id = uuid4() + + with pytest.raises(ValidationError, match="twice"): + CallEndedReport(modal_call_id=CALL_ID, simulation_run_ids=(run_id, run_id)) + + +@pytest.mark.parametrize( + "call_id", ["", "fc-", "in-01JB3W7X9Z", "fc-01JB/../x", "fc-" + "A" * 80] +) +def test_a_report_names_a_modal_function_call(call_id) -> None: + with pytest.raises(ValidationError): + CallEndedReport(modal_call_id=call_id, report_run_id=uuid4()) + + +def test_a_report_carries_nothing_else() -> None: + with pytest.raises(ValidationError): + CallEndedReport.model_validate( + {"modal_call_id": CALL_ID, "report_run_id": str(uuid4()), "result": {}} + ) + + +def test_the_sender_retries_only_while_sim_cannot_record_yet() -> None: + assert { + CallReportStatus.NOT_DISPATCHED_YET, + CallReportStatus.STILL_RUNNING, + CallReportStatus.MODAL_UNREACHABLE, + CallReportStatus.NO_RESULT_RECORDER, + CallReportStatus.NOT_WRITTEN, + CallReportStatus.NOT_CONFIGURED, + } == RETRY_LATER_STATUSES + assert CallReportStatus.RECORDED not in RETRY_LATER_STATUSES + assert CallReportStatus.UNKNOWN_RUNS not in RETRY_LATER_STATUSES + + +def test_an_answer_says_whether_to_report_again() -> None: + assert CallReportAnswer(status=CallReportStatus.STILL_RUNNING).retry_later + assert not CallReportAnswer(status=CallReportStatus.ALREADY_RECORDED).retry_later From 3e0e4db673ac7fed5f3e4abc5beb7db916ac8623 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:59:57 +0400 Subject: [PATCH 066/124] Check that a request comes from sim's call-report Modal function Modal gives every function container an RS256 identity token from https://oidc.modal.com whose claims name its workspace, environment, app and function. ModalIdentityVerifier checks a token against Modal's published keys (its JWKS), its issuer, audience and expiry, and accepts it only when it names the configured workspace and environment, a v2 app and the report_call_ended function, so no other Modal function, such as a worker running a user's policy, can report a call's end. When Modal's keys cannot be fetched it raises ModalKeysUnavailableError instead of refusing the token, so the sender reports again later. The expected workspace and environment come from SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID and SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT, set together or not at all. Co-Authored-By: Claude Opus 5.5 --- .../policyengine_simulation_entry/config.py | 48 ++++++ .../modal_identity.py | 149 ++++++++++++++++++ .../tests/test_config.py | 42 ++++- .../tests/test_modal_identity.py | 149 ++++++++++++++++++ 4 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py create mode 100644 projects/policyengine-simulation-entry/tests/test_modal_identity.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 5d23e8c54..6ac11436f 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from dataclasses import dataclass from urllib.parse import urlparse @@ -12,10 +13,15 @@ assert_separate_manifest_names, ) +from policyengine_simulation_entry.modal_identity import ExpectedModalCaller + PRODUCTION_ENVIRONMENTS = frozenset({"main", "prod", "production"}) MODAL_GATEWAY_HOST_SUFFIX = ".modal.run" # Stage 12 comparison artifacts and rows are kept for exactly this many days. REQUIRED_STAGE12_RETENTION_DAYS = 30 +# Modal workspace IDs and environment names, as Modal prints them. +MODAL_WORKSPACE_ID_PATTERN = re.compile(r"ac-[A-Za-z0-9]+") +MODAL_ENVIRONMENT_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{0,63}") class ConfigurationError(RuntimeError): @@ -96,6 +102,10 @@ class Settings: # outside deployed environments; nothing on the old-gateway or Stage 12 # paths reads it. simulation_database_url: str = "" + # The Modal workspace and environment whose call-report function may + # report that calls ended. Both empty: the reporting route answers 503. + call_report_modal_workspace_id: str = "" + call_report_modal_environment: str = "" @classmethod def from_env(cls) -> Settings: @@ -147,6 +157,12 @@ def from_env(cls) -> Settings: stage12_artifact_bucket=os.getenv("STAGE12_ARTIFACT_BUCKET", ""), stage12_retention_days=int(os.getenv("STAGE12_RETENTION_DAYS", "30")), simulation_database_url=os.getenv("SIMULATION_DATABASE_URL", ""), + call_report_modal_workspace_id=os.getenv( + "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID", "" + ), + call_report_modal_environment=os.getenv( + "SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT", "" + ), ) @property @@ -154,6 +170,18 @@ def production(self) -> bool: """Whether this is a production environment, where auth must stay on.""" return self.environment in PRODUCTION_ENVIRONMENTS + @property + def expected_modal_caller(self) -> ExpectedModalCaller | None: + """The Modal caller whose call-ended reports are accepted, if configured.""" + if not ( + self.call_report_modal_workspace_id and self.call_report_modal_environment + ): + return None + return ExpectedModalCaller( + workspace_id=self.call_report_modal_workspace_id, + environment_name=self.call_report_modal_environment, + ) + @property def stage12_resources_configured(self) -> bool: """Whether the Stage 12 manifest environment, database and bucket are set.""" @@ -176,6 +204,7 @@ def validate(self) -> None: self._validate_old_gateway() self._validate_stage12() self._validate_simulation_database() + self._validate_call_reports() def _validate_caller_auth(self) -> None: """Require caller authentication in production, with an HTTPS issuer.""" @@ -281,3 +310,22 @@ def _validate_simulation_database(self) -> None: ): msg = "SIMULATION_DATABASE_URL must use the canonical postgresql:// scheme." raise ConfigurationError(msg) + + def _validate_call_reports(self) -> None: + """Require one Modal workspace ID and environment name, or neither.""" + workspace = self.call_report_modal_workspace_id + environment = self.call_report_modal_environment + if not workspace and not environment: + return + if not MODAL_WORKSPACE_ID_PATTERN.fullmatch(workspace): + msg = ( + "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID must be a Modal " + "workspace ID." + ) + raise ConfigurationError(msg) + if not MODAL_ENVIRONMENT_NAME_PATTERN.fullmatch(environment): + msg = ( + "SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT must be a Modal " + "environment name." + ) + raise ConfigurationError(msg) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py new file mode 100644 index 000000000..cbd971194 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py @@ -0,0 +1,149 @@ +"""Check that a request comes from sim's call-report Modal function. + +Modal gives every function container an OIDC identity token +(``MODAL_IDENTITY_TOKEN``), signed with RS256 by ``MODAL_OIDC_ISSUER``, whose +claims name the container's workspace, environment, app and function. A +call-ended report carries it as a bearer token. The entrypoint accepts it +only when Modal's published key verifies its signature, its issuer, audience +and expiry are valid, and it names the expected workspace and environment, a +v2 app (``V2_APPLICATION_PREFIX``) and the call-report function +(``CALL_REPORT_FUNCTION_NAME``). A token of any other Modal function, such as +a worker running a user's policy, is refused. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +import jwt +from policyengine_simulation_contract.call_reports import CALL_REPORT_FUNCTION_NAME +from policyengine_simulation_contract.stage12_manifest import V2_APPLICATION_PREFIX + +if TYPE_CHECKING: + from collections.abc import Mapping + +MODAL_OIDC_ISSUER = "https://oidc.modal.com" +MODAL_OIDC_AUDIENCE = "oidc.modal.com" +MODAL_JWKS_URL = "https://oidc.modal.com/.well-known/jwks.json" +# Modal lists RS256 as its only signing algorithm. +MODAL_SIGNING_ALGORITHMS = ("RS256",) +# Tolerated clock difference between Modal and this instance. +CLOCK_LEEWAY_SECONDS = 30 + + +class ModalIdentityError(Exception): + """The token does not prove that the request comes from the expected caller.""" + + +class ModalKeysUnavailableError(Exception): + """Modal's published keys could not be fetched, so no token can be checked.""" + + +class SigningKeySource(Protocol): + """Where the key that signed a token is found (``jwt.PyJWKClient``).""" + + def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK: + """Return the published key that signed ``token``.""" + ... + + +@dataclass(frozen=True) +class ExpectedModalCaller: + """The only Modal caller whose reports are accepted.""" + + workspace_id: str + environment_name: str + app_name_prefix: str = V2_APPLICATION_PREFIX + function_name: str = CALL_REPORT_FUNCTION_NAME + + def __post_init__(self) -> None: + """Refuse an expectation that any workspace or environment would meet.""" + if not self.workspace_id: + msg = "the expected Modal workspace is not set" + raise ValueError(msg) + if not self.environment_name: + msg = "the expected Modal environment is not set" + raise ValueError(msg) + + +@dataclass(frozen=True) +class ModalIdentity: + """The verified claims of a Modal container's identity token.""" + + app_name: str + function_name: str + container_id: str + + +def _claim(claims: Mapping[str, object], name: str) -> str: + value = claims.get(name) + return value if isinstance(value, str) else "" + + +class ModalIdentityVerifier: + """Verify Modal identity tokens against Modal's published keys.""" + + def __init__( + self, + expected: ExpectedModalCaller, + *, + signing_keys: SigningKeySource | None = None, + ) -> None: + """Expect ``expected``; fetch keys from ``MODAL_JWKS_URL`` unless given.""" + self.expected = expected + self.signing_keys: SigningKeySource = signing_keys or jwt.PyJWKClient( + MODAL_JWKS_URL + ) + + def verify(self, token: str) -> ModalIdentity: + """Return the token's identity, or raise ModalIdentityError. + + Raise ModalKeysUnavailableError when Modal's keys cannot be fetched. + Fetching them makes a network request the first time and whenever + the key cache expires, so call this in a worker thread. + """ + try: + signing_key = self.signing_keys.get_signing_key_from_jwt(token).key + except jwt.PyJWKClientConnectionError as error: + msg = "Modal's signing keys could not be fetched" + raise ModalKeysUnavailableError(msg) from error + except jwt.PyJWTError as error: + msg = f"the token names no key Modal publishes: {type(error).__name__}" + raise ModalIdentityError(msg) from error + try: + claims = jwt.decode( + token, + signing_key, + algorithms=list(MODAL_SIGNING_ALGORITHMS), + audience=MODAL_OIDC_AUDIENCE, + issuer=MODAL_OIDC_ISSUER, + leeway=CLOCK_LEEWAY_SECONDS, + options={"require": ["exp", "iat", "iss", "aud"]}, + ) + except jwt.PyJWTError as error: + msg = f"the token is not valid: {type(error).__name__}" + raise ModalIdentityError(msg) from error + return self._caller(claims) + + def _caller(self, claims: Mapping[str, object]) -> ModalIdentity: + expected = self.expected + if _claim(claims, "workspace_id") != expected.workspace_id: + msg = "the token names another Modal workspace" + raise ModalIdentityError(msg) + if _claim(claims, "environment_name") != expected.environment_name: + msg = "the token names another Modal environment" + raise ModalIdentityError(msg) + app_name = _claim(claims, "app_name") + if not app_name.startswith(expected.app_name_prefix): + msg = "the token names another Modal app" + raise ModalIdentityError(msg) + function_name = _claim(claims, "function_name") + if function_name != expected.function_name: + msg = "the token names another Modal function" + raise ModalIdentityError(msg) + return ModalIdentity( + app_name=app_name, + function_name=function_name, + container_id=_claim(claims, "container_id"), + ) diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py index 23ed45320..eb8a6b72e 100644 --- a/projects/policyengine-simulation-entry/tests/test_config.py +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -2,7 +2,7 @@ import pytest from conftest import make_settings -from policyengine_simulation_entry.config import ConfigurationError +from policyengine_simulation_entry.config import ConfigurationError, Settings def test_production_refuses_disabled_auth(): @@ -335,3 +335,43 @@ def test_the_simulation_database_url_must_be_canonical(database_url): make_settings(simulation_database_url=database_url).validate() assert "secret" not in str(error.value) + + +def test_call_reports_are_off_without_a_modal_workspace(monkeypatch): + monkeypatch.delenv("SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID", raising=False) + monkeypatch.delenv("SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT", raising=False) + + settings = Settings.from_env() + + assert settings.expected_modal_caller is None + + +def test_call_reports_expect_the_configured_workspace_and_environment(monkeypatch): + monkeypatch.setenv("SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID", "ac-workspace0001") + monkeypatch.setenv("SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT", "staging") + + expected = Settings.from_env().expected_modal_caller + + assert expected is not None + assert expected.workspace_id == "ac-workspace0001" + assert expected.environment_name == "staging" + + +@pytest.mark.parametrize( + ("workspace", "environment"), + [ + ("ac-workspace0001", ""), + ("", "staging"), + ("workspace0001", "staging"), + ("ac-workspace0001", "Not An Environment"), + ], + ids=["no-environment", "no-workspace", "not-a-workspace-id", "bad-environment"], +) +def test_call_report_settings_must_name_one_modal_workspace_and_environment( + workspace, environment +): + with pytest.raises(ConfigurationError, match="SIMULATION_CALL_REPORT"): + make_settings( + call_report_modal_workspace_id=workspace, + call_report_modal_environment=environment, + ).validate() diff --git a/projects/policyengine-simulation-entry/tests/test_modal_identity.py b/projects/policyengine-simulation-entry/tests/test_modal_identity.py new file mode 100644 index 000000000..5cd0eade2 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_modal_identity.py @@ -0,0 +1,149 @@ +"""Checking that a request comes from sim's call-report Modal function.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from policyengine_simulation_entry.modal_identity import ( + MODAL_OIDC_AUDIENCE, + MODAL_OIDC_ISSUER, + ExpectedModalCaller, + ModalIdentityError, + ModalIdentityVerifier, + ModalKeysUnavailableError, +) + +WORKSPACE_ID = "ac-workspace0001" +EXPECTED = ExpectedModalCaller(workspace_id=WORKSPACE_ID, environment_name="staging") +SIGNING_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@dataclass +class _KeyFromJwks: + """Stands in for jwt.PyJWKClient: the public key Modal publishes.""" + + public_key: object + fetches: int = 0 + + def get_signing_key_from_jwt(self, _token: str): + self.fetches += 1 + return jwt.PyJWK.from_dict( + { + **jwt.algorithms.RSAAlgorithm.to_jwk(self.public_key, as_dict=True), + "kid": "modal-key", + "alg": "RS256", + } + ) + + +def _claims(**overrides): + now = int(time.time()) + claims = { + "iss": MODAL_OIDC_ISSUER, + "aud": MODAL_OIDC_AUDIENCE, + "sub": "modal:workspace_id:ac-workspace0001:...", + "iat": now, + "exp": now + 3600, + "workspace_id": WORKSPACE_ID, + "environment_name": "staging", + "app_name": "policyengine-simulation-v2-py6-10-0", + "function_name": "report_call_ended", + "container_id": "ta-container0001", + } + claims.update(overrides) + return {name: value for name, value in claims.items() if value is not None} + + +def _token(key=SIGNING_KEY, **overrides) -> str: + return jwt.encode( + _claims(**overrides), key, algorithm="RS256", headers={"kid": "modal-key"} + ) + + +def _verifier() -> ModalIdentityVerifier: + return ModalIdentityVerifier( + EXPECTED, signing_keys=_KeyFromJwks(SIGNING_KEY.public_key()) + ) + + +def test_a_token_of_the_call_report_function_is_accepted() -> None: + identity = _verifier().verify(_token()) + + assert identity.app_name == "policyengine-simulation-v2-py6-10-0" + assert identity.function_name == "report_call_ended" + assert identity.container_id == "ta-container0001" + + +@pytest.mark.parametrize( + ("overrides", "reason"), + [ + ({"workspace_id": "ac-someoneelse"}, "workspace"), + ({"environment_name": "main"}, "environment"), + ({"app_name": "policyengine-simulation-py6-10-0"}, "app"), + ({"function_name": "coordinate_report"}, "function"), + ({"workspace_id": None}, "workspace"), + ({"function_name": None}, "function"), + ], + ids=[ + "other-workspace", + "other-environment", + "main-path-app", + "other-function", + "no-workspace", + "no-function", + ], +) +def test_a_token_of_any_other_modal_caller_is_refused(overrides, reason) -> None: + with pytest.raises(ModalIdentityError, match=reason): + _verifier().verify(_token(**overrides)) + + +@pytest.mark.parametrize( + "token", + [ + _token(key=OTHER_KEY), + _token(iss="https://oidc.example.com"), + _token(aud="sim"), + _token(exp=int(time.time()) - 3600), + "not-a-token", + ], + ids=["wrong-key", "wrong-issuer", "wrong-audience", "expired", "malformed"], +) +def test_a_token_modal_did_not_sign_for_us_is_refused(token) -> None: + with pytest.raises(ModalIdentityError): + _verifier().verify(token) + + +def test_a_token_signed_with_another_algorithm_is_refused() -> None: + token = jwt.encode( + _claims(), "shared-secret-of-at-least-32-bytes!", algorithm="HS256" + ) + + with pytest.raises(ModalIdentityError): + _verifier().verify(token) + + +def test_the_expected_caller_needs_a_workspace_and_an_environment() -> None: + with pytest.raises(ValueError, match="workspace"): + ExpectedModalCaller(workspace_id="", environment_name="staging") + with pytest.raises(ValueError, match="environment"): + ExpectedModalCaller(workspace_id=WORKSPACE_ID, environment_name="") + + +class _UnreachableJwks: + def get_signing_key_from_jwt(self, _token: str): + msg = "Fail to fetch data from the url" + raise jwt.PyJWKClientConnectionError(msg) + + +def test_unreachable_modal_keys_are_not_a_refusal() -> None: + """The sender should report again later, not give up on its report.""" + verifier = ModalIdentityVerifier(EXPECTED, signing_keys=_UnreachableJwks()) + + with pytest.raises(ModalKeysUnavailableError): + verifier.verify(_token()) From fe94717216155d1be04b0aa20107e74e18412f47 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:04:26 +0400 Subject: [PATCH 067/124] Check a running call again when its claim's lease ends, without backoff The per-call backoff served a loop that asked Modal about running calls every few seconds. With Modal reporting when a call ends, a recording pass is only the safety check for calls that did not report, so a call that is still running, or finished but of a kind with no recorder here, is now left as claimed: it is due again when its claim's lease ends, and nothing is written. next_check_delay and the two writes that set a later check (record_simulation_call_running, record_comparison_running) are gone; the load check leaves such calls for after the lease too. Co-Authored-By: Claude Opus 5.5 --- .../run_transitions.py | 26 -------- .../tests/test_recording_load.py | 7 +-- .../tests/test_run_transitions.py | 18 ------ .../run_recording.py | 50 ++------------- .../tests/run_fakes.py | 20 ------ .../tests/test_run_recording.py | 61 +++++++------------ 6 files changed, 30 insertions(+), 152 deletions(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 0d0a2e5aa..7686d639b 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -282,17 +282,6 @@ def _change_simulation_call( ) return tuple(SimulationRunRecord.from_row(row) for row in rows) - def record_simulation_call_running( - self, modal_call_id: str, *, next_check_at: datetime - ) -> bool: - """Set the next check of a call's runs that are still running.""" - with self.engine.begin() as connection: - return bool( - self._change_simulation_call( - connection, modal_call_id, {"next_check_at": next_check_at} - ) - ) - def complete_simulation_call( self, modal_call_id: str, write_results: SimulationResultWriter ) -> bool: @@ -487,21 +476,6 @@ def claim_due_comparisons( ) return [ReportRunRecord.from_row(row) for row in rows] - def record_comparison_running( - self, report_run_id: UUID, *, next_check_at: datetime - ) -> bool: - """Set the next check of a comparison step that is still running.""" - with self.engine.begin() as connection: - return ( - self._change_comparison( - connection, - report_run_id, - expected=RunStatus.RUNNING, - values={"comparison_next_check_at": next_check_at}, - ) - is not None - ) - def complete_comparison( self, report_run_id: UUID, write_results: ComparisonResultWriter ) -> bool: diff --git a/libs/policyengine-simulation-persistence/tests/test_recording_load.py b/libs/policyengine-simulation-persistence/tests/test_recording_load.py index 048151da5..00df05421 100644 --- a/libs/policyengine-simulation-persistence/tests/test_recording_load.py +++ b/libs/policyengine-simulation-persistence/tests/test_recording_load.py @@ -148,10 +148,9 @@ def _instance(database, clock, outcomes, barrier, seed: int) -> int: for call in calls: outcome = chooser.random() if outcome < 0.3: - transitions.record_simulation_call_running( - call.modal_call_id, next_check_at=clock() - ) - elif outcome < 0.5: + # Still running: checked again once the claim's lease ends. + continue + if outcome < 0.5: late.append((call.modal_call_id, outcome < 0.45)) else: _record( diff --git a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py index ad1da794a..eaec97a5d 100644 --- a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py @@ -260,21 +260,6 @@ def claim(_instance: int) -> list[str]: assert len(claimed_calls) == len(set(claimed_calls)) == len(run_ids) -def test_a_running_call_gets_its_next_check(migrated_database, public_api_rows) -> None: - stores = clocked_stores(migrated_database) - run = _running_run(stores, migrated_database, public_api_rows) - later = stores.clock() + timedelta(seconds=30) - - assert stores.transitions.record_simulation_call_running( - "fc-economy", next_check_at=later - ) - - assert stores.runs.get_simulation_run(run.id).next_check_at == later - assert not stores.transitions.record_simulation_call_running( - "fc-unknown", next_check_at=later - ) - - def test_results_are_written_in_the_transaction_that_marks_success( migrated_database, public_api_rows ) -> None: @@ -393,9 +378,6 @@ def test_a_comparison_runs_and_records_its_results_once( assert [due.id for due in claimed] == [report_run.id] assert claimed[0].comparison_check_count == 1 assert transitions.claim_due_comparisons(limit=10, lease=LEASE) == [] - assert transitions.record_comparison_running( - report_run.id, next_check_at=stores.clock() + LEASE - ) assert transitions.complete_comparison( report_run.id, lambda _connection, record: written.append(record) ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index be934c947..15331ec24 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -12,7 +12,7 @@ its result rows are written in the transaction that marks its runs ``succeeded``; a failed or timed-out call is marked ``failed`` with its error; a call still running, or finished but of a kind this revision has - no recorder for, gets a later check. + no recorder for, is checked again once its claim's lease ends. Pre-emption is never a failure: Modal re-runs a pre-empted call under the same call ID, so it reads as still running. No caller polls, and Modal holds @@ -66,8 +66,6 @@ def _event_name(prefix: str, outcome: str) -> str: CHECK_LEASE = timedelta(minutes=1) # A run left dispatching this long was claimed by an instance that stopped. DISPATCH_ABANDONED_AFTER = timedelta(minutes=10) -FIRST_BACKOFF = timedelta(seconds=2) -LONGEST_BACKOFF = timedelta(seconds=60) CLAIM_BATCH_SIZE = 50 # Each check writes through the runtime engine's small pool, which requests # share (policyengine_simulation_persistence.engine: 2 connections plus 3 @@ -75,17 +73,6 @@ def _event_name(prefix: str, outcome: str) -> str: INSPECTION_CONCURRENCY = 3 -# Past this many doublings the wait is LONGEST_BACKOFF anyway; capping the -# exponent keeps the product within timedelta's range for a long call. -_MOST_DOUBLINGS = 16 - - -def next_check_delay(check_count: int) -> timedelta: - """Return the wait before the next check of a call checked ``check_count`` times.""" - doublings = min(max(check_count - 1, 0), _MOST_DOUBLINGS) - return min(FIRST_BACKOFF * 2**doublings, LONGEST_BACKOFF) - - class SimulationResultRecorder(Protocol): """Validate one kind of simulation call's output and write its result rows.""" @@ -142,12 +129,6 @@ def claim_due_simulation_calls( """Claim running simulation calls that are due a check.""" ... - def record_simulation_call_running( - self, modal_call_id: str, *, next_check_at: datetime - ) -> bool: - """Set a running call's next check.""" - ... - def complete_simulation_call( self, modal_call_id: str, @@ -166,12 +147,6 @@ def claim_due_comparisons( """Claim running comparison steps that are due a check.""" ... - def record_comparison_running( - self, report_run_id: UUID, *, next_check_at: datetime - ) -> bool: - """Set a running comparison step's next check.""" - ... - def complete_comparison( self, report_run_id: UUID, @@ -232,10 +207,8 @@ class _CallHandlers: subject: dict[str, object] # "simulation_run" or "report_run_comparison": the log event names' start. event_prefix: str - check_count: int result_steps: _ResultSteps | None fail: Callable[[RunError], bool] - keep_running: Callable[[datetime], bool] def _invalid_result(error: ValueError) -> RunError: @@ -347,14 +320,8 @@ def _simulation_handlers( "simulation_run_ids": [str(run.id) for run in call.runs], }, event_prefix="simulation_run", - check_count=max(run.check_count for run in call.runs), result_steps=result_steps, fail=lambda error: self.transitions.fail_simulation_call(call_id, error), - keep_running=lambda next_check_at: ( - self.transitions.record_simulation_call_running( - call_id, next_check_at=next_check_at - ) - ), ) def _comparison_handlers( @@ -376,14 +343,8 @@ def _comparison_handlers( return call_id, _CallHandlers( subject={"modal_call_id": call_id, "report_run_id": str(report_run_id)}, event_prefix="report_run_comparison", - check_count=report_run.comparison_check_count, result_steps=result_steps, fail=lambda error: self.transitions.fail_comparison(report_run_id, error), - keep_running=lambda next_check_at: ( - self.transitions.record_comparison_running( - report_run_id, next_check_at=next_check_at - ) - ), ) async def _check( @@ -410,9 +371,9 @@ async def _check( async def _record( self, outcome: CallOutcome, handlers: _CallHandlers, recording: RecordingPass ) -> None: + # A call still running, or of a kind with no recorder here, is checked + # again once its claim's lease ends; nothing is written. if isinstance(outcome, CallRunning): - next_check_at = self.clock() + next_check_delay(handlers.check_count) - await asyncio.to_thread(handlers.keep_running, next_check_at) recording.still_running += 1 return if isinstance(outcome, CallFailed): @@ -420,11 +381,8 @@ async def _record( return steps = handlers.result_steps if steps is None: - # During a deploy an older revision runs beside the new one. A - # revision without a recorder for this kind leaves the call for + # A revision without a recorder for this kind leaves the call for # one that has it; Modal's output retention bounds the wait. - next_check_at = self.clock() + next_check_delay(handlers.check_count) - await asyncio.to_thread(handlers.keep_running, next_check_at) recording.without_recorder += 1 logger.warning( _event_name(handlers.event_prefix, "without_result_recorder"), diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index 232e0eb0c..de01244a9 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -286,15 +286,6 @@ def _call_runs(self, modal_call_id: str) -> list[FakeExecution]: if run.modal_call_id == modal_call_id and run.status is RunStatus.RUNNING ] - def record_simulation_call_running( - self, modal_call_id: str, *, next_check_at: datetime - ) -> bool: - """Set the next check of a call's running runs.""" - runs = self._call_runs(modal_call_id) - for run in runs: - run.next_check_at = next_check_at - return bool(runs) - def complete_simulation_call(self, modal_call_id: str, write_results) -> bool: """Mark a call's runs succeeded after the writer succeeds.""" runs = self._call_runs(modal_call_id) @@ -351,17 +342,6 @@ def claim_due_comparisons( for comparison in due ] - def record_comparison_running( - self, report_run_id: UUID, *, next_check_at: datetime - ) -> bool: - """Set a running comparison step's next check.""" - return ( - self._change_comparison( - report_run_id, RunStatus.RUNNING, next_check_at=next_check_at - ) - is not None - ) - def complete_comparison(self, report_run_id: UUID, write_results) -> bool: """Mark a running comparison step succeeded after the writer succeeds.""" comparison = self.comparisons[report_run_id] diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index 5f9dde2aa..a47f93b73 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -21,7 +21,6 @@ RecordingPass, ResultRecorders, RunRecorder, - next_check_delay, ) from policyengine_simulation_persistence import ( HEAD_REVISION, @@ -40,6 +39,9 @@ class BudgetResult(BaseModel): budgetary_impact: float +WORKER_ERROR = RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised KeyError.") + + class RecordingRecorder: """Validate outputs as ``BudgetResult`` and remember what it wrote.""" @@ -73,25 +75,7 @@ def _run(recorder) -> RecordingPass: return asyncio.run(recorder.record_due_outcomes()) -@pytest.mark.parametrize( - ("check_count", "seconds"), - [ - (0, 2), - (1, 2), - (2, 4), - (3, 8), - (5, 32), - (6, 60), - (40, 60), - (47, 60), - (10_000, 60), - ], -) -def test_checks_back_off_up_to_a_minute(check_count, seconds) -> None: - assert next_check_delay(check_count) == timedelta(seconds=seconds) - - -def test_a_running_call_is_checked_again_later() -> None: +def test_a_running_call_is_checked_again_after_its_claims_lease() -> None: recorder, transitions, inspector, clock = _recorder() (run_id,) = transitions.running_call("fc-1") @@ -102,8 +86,10 @@ def test_a_running_call_is_checked_again_later() -> None: run = transitions.runs[run_id] assert run.status is RunStatus.RUNNING assert run.check_count == 1 - assert run.next_check_at == clock() + timedelta(seconds=2) + assert run.next_check_at == clock() + CHECK_LEASE assert _run(recorder).checked == 0 + clock.advance(CHECK_LEASE) + assert _run(recorder).checked == 1 def test_a_failed_call_fails_its_runs() -> None: @@ -169,7 +155,7 @@ def test_a_kind_without_a_recorder_waits_for_an_instance_with_one() -> None: run = transitions.runs[run_id] assert run.status is RunStatus.RUNNING assert run.error is None - assert run.next_check_at == clock() + timedelta(seconds=2) + assert run.next_check_at == clock() + CHECK_LEASE def test_an_invalid_output_fails_its_runs_without_repeating_it() -> None: @@ -263,9 +249,7 @@ def test_comparison_steps_are_checked_and_recorded() -> None: assert recording.still_running == 1 assert transitions.comparisons[finished].status is RunStatus.SUCCEEDED assert [record.id for _, record, _ in comparisons.writes] == [finished] - assert transitions.comparisons[waiting].next_check_at == clock() + timedelta( - seconds=2 - ) + assert transitions.comparisons[waiting].next_check_at == clock() + CHECK_LEASE def test_dispatches_left_for_ten_minutes_are_failed_by_a_pass() -> None: @@ -322,18 +306,17 @@ def test_a_database_error_in_one_check_ends_with_its_pass(caplog) -> None: recorder, transitions, inspector, clock = _recorder() (broken_run,) = transitions.running_call("fc-broken") (failed_run,) = transitions.running_call("fc-failed") - inspector.outcomes["fc-failed"] = CallFailed( - RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised KeyError.") - ) - keep_running = transitions.record_simulation_call_running + for call_id in ("fc-broken", "fc-failed"): + inspector.outcomes[call_id] = CallFailed(WORKER_ERROR) + fail_call = transitions.fail_simulation_call - def drop_one_connection(modal_call_id, *, next_check_at): + def drop_one_connection(modal_call_id, error): if modal_call_id == "fc-broken": msg = "connection lost" raise ConnectionError(msg) - return keep_running(modal_call_id, next_check_at=next_check_at) + return fail_call(modal_call_id, error) - transitions.record_simulation_call_running = drop_one_connection # type: ignore[method-assign] + transitions.fail_simulation_call = drop_one_connection # type: ignore[method-assign] recording = _run(recorder) @@ -363,14 +346,15 @@ class _NotAnException(BaseException): def test_a_non_exception_error_in_one_check_ends_with_its_pass(caplog) -> None: - recorder, transitions, _, clock = _recorder() + recorder, transitions, inspector, clock = _recorder() (run_id,) = transitions.running_call("fc-broken") + inspector.outcomes["fc-broken"] = CallFailed(WORKER_ERROR) - def raise_a_group(modal_call_id, *, next_check_at): + def raise_a_group(modal_call_id, error): message = "writes" raise builtins.BaseExceptionGroup(message, [_NotAnException()]) - transitions.record_simulation_call_running = raise_a_group # type: ignore[method-assign] + transitions.fail_simulation_call = raise_a_group # type: ignore[method-assign] recording = _run(recorder) @@ -382,13 +366,14 @@ def raise_a_group(modal_call_id, *, next_check_at): def test_a_signal_to_this_process_stops_a_check() -> None: - recorder, transitions, _, _ = _recorder() + recorder, transitions, inspector, _ = _recorder() transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallFailed(WORKER_ERROR) - def interrupted(modal_call_id, *, next_check_at): + def interrupted(modal_call_id, error): raise KeyboardInterrupt - transitions.record_simulation_call_running = interrupted # type: ignore[method-assign] + transitions.fail_simulation_call = interrupted # type: ignore[method-assign] with pytest.raises(KeyboardInterrupt): _run(recorder) From 9bc28bb276804a1ede40879526640a47ce663225 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:08:04 +0400 Subject: [PATCH 068/124] Record a call's outcome when its Modal function reports that it ended RunRecorder.record_reported_call takes a CallEndedReport, reads the runs (or the comparison step) it names, asks Modal about the call without waiting, and records the outcome with the same conditional writes as a recording pass, returning a CallReportStatus. A report is only a hint, so it records nothing it cannot confirm: - runs that hold the call and are running: the outcome is recorded; - runs that already finished with the call: already_recorded, and Modal is not asked again, so a repeated report changes nothing; - runs still being dispatched, whose call ID is not stored yet (a fast household call can report first): not_dispatched_yet; - a call Modal still runs, Modal or the database unreachable, or a kind without a recorder: still_running, modal_unreachable, not_written or no_result_recorder, with nothing written; - unknown runs, or runs holding another call or finished without one: unknown_runs or other_call. Only a signal to this process propagates. Recording one call now returns what it did, which a pass counts and a report maps to its status, and log records name the error an error was raised from (error_cause_type), so a CallInspectionError no longer hides the Modal error behind it. Co-Authored-By: Claude Opus 5.5 --- .../run_recording.py | 240 ++++++++++++--- .../tests/run_fakes.py | 30 ++ .../tests/test_call_report_recording.py | 280 ++++++++++++++++++ .../tests/test_run_recording.py | 1 + 4 files changed, 515 insertions(+), 36 deletions(-) create mode 100644 projects/policyengine-simulation-entry/tests/test_call_report_recording.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 15331ec24..72a64d83d 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -25,9 +25,16 @@ import logging from dataclasses import dataclass, field from datetime import timedelta +from enum import StrEnum from typing import TYPE_CHECKING, Protocol -from policyengine_simulation_persistence import RunError, RunErrorCode +from policyengine_simulation_contract.call_reports import CallReportStatus +from policyengine_simulation_persistence import ( + DueSimulationCall, + RunError, + RunErrorCode, + RunStatus, +) from policyengine_simulation_persistence.run_store import utc_now from policyengine_simulation_entry.modal_calls import ( @@ -41,9 +48,9 @@ from datetime import datetime from uuid import UUID + from policyengine_simulation_contract.call_reports import CallEndedReport from policyengine_simulation_persistence import ( AbandonedDispatches, - DueSimulationCall, ReportKind, ReportRunRecord, SimulationKind, @@ -170,6 +177,42 @@ def fail_comparisons_of_failed_runs(self) -> tuple[UUID, ...]: ... +class RunReader(Protocol): + """The ``RunStore`` reads a reported call needs.""" + + def get_simulation_run(self, run_id: UUID) -> SimulationRunRecord | None: + """Return the simulation run with this ID, or None.""" + ... + + def get_report_run(self, report_run_id: UUID) -> ReportRunRecord | None: + """Return the report run with this ID, or None.""" + ... + + +class _Recorded(StrEnum): + """What recording one call's outcome did.""" + + SUCCEEDED = "succeeded" + FAILED = "failed" + STILL_RUNNING = "still_running" + WITHOUT_RECORDER = "without_recorder" + NOT_WRITTEN = "not_written" + # Another writer recorded the outcome first. + UNCHANGED = "unchanged" + + +_REPORT_STATUSES = { + _Recorded.SUCCEEDED: CallReportStatus.RECORDED, + _Recorded.FAILED: CallReportStatus.RECORDED, + _Recorded.STILL_RUNNING: CallReportStatus.STILL_RUNNING, + _Recorded.WITHOUT_RECORDER: CallReportStatus.NO_RESULT_RECORDER, + _Recorded.NOT_WRITTEN: CallReportStatus.NOT_WRITTEN, + _Recorded.UNCHANGED: CallReportStatus.ALREADY_RECORDED, +} +# A run or comparison step in these statuses may not hold its call ID yet. +_NOT_YET_DISPATCHED = frozenset({RunStatus.PENDING, RunStatus.DISPATCHING}) + + @dataclass class RecordingPass: """What one pass found and recorded.""" @@ -188,6 +231,19 @@ def found_work(self) -> bool: """Whether the pass checked or failed anything.""" return bool(self.checked or self.abandoned or self.comparisons_without_runs) + def count(self, recorded: _Recorded) -> None: + """Count what recording one checked call did.""" + counters = { + _Recorded.SUCCEEDED: "succeeded", + _Recorded.FAILED: "failed", + _Recorded.STILL_RUNNING: "still_running", + _Recorded.WITHOUT_RECORDER: "without_recorder", + _Recorded.NOT_WRITTEN: "not_reached", + } + counter = counters.get(recorded) + if counter is not None: + setattr(self, counter, getattr(self, counter) + 1) + @dataclass(frozen=True) class _ResultSteps: @@ -222,9 +278,10 @@ def _invalid_result(error: ValueError) -> RunError: @dataclass class RunRecorder: - """Record the outcomes of due Modal calls, one pass at a time.""" + """Record Modal call outcomes: one reported call, or a pass of due checks.""" transitions: RecordingTransitions + runs: RunReader inspector: CallInspector recorders: ResultRecorders = field(default_factory=ResultRecorders) clock: Callable[[], datetime] = utc_now @@ -353,7 +410,7 @@ async def _check( recording.checked += 1 try: outcome = await self.inspector.inspect(modal_call_id) - await self._record(outcome, handlers, recording) + recorded = await self._record(outcome, handlers) # Modal could not be reached, or a database write failed (for example # a dropped connection). Each check ends here, whatever was raised # (only a signal to this process passes), so a pass returns only once @@ -365,59 +422,170 @@ async def _check( recording.not_reached += 1 logger.warning( _event_name(handlers.event_prefix, "check_failed"), - extra={**handlers.subject, "error_type": type(error).__name__}, + extra={**handlers.subject, **_error_fields(error)}, ) + return + recording.count(recorded) + + async def record_reported_call(self, report: CallEndedReport) -> CallReportStatus: + """Record the outcome of a call whose Modal function reported its end. + + The report is only a hint: the runs are read, Modal is asked about the + call without waiting, and the outcome is recorded with the same + conditional writes as a recording pass, so a repeated report changes + nothing. While the outcome cannot be recorded yet, the answer is one + of ``RETRY_LATER_STATUSES`` and nothing is written; the sender + reports again, and the safety check finds the call otherwise. Only a + signal to this process propagates. + """ + try: + found = await asyncio.to_thread(self._reported_call, report) + except Exception as error: # the database could not be read + logger.exception( + "call_report_not_read", + extra={"modal_call_id": report.modal_call_id, **_error_fields(error)}, + ) + return CallReportStatus.NOT_WRITTEN + if isinstance(found, CallReportStatus): + return found + try: + outcome = await self.inspector.inspect(report.modal_call_id) + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise + logger.warning( + _event_name(found.event_prefix, "check_failed"), + extra={**found.subject, **_error_fields(error)}, + ) + return CallReportStatus.MODAL_UNREACHABLE + try: + recorded = await self._record(outcome, found) + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise + logger.warning( + _event_name(found.event_prefix, "outcome_not_written"), + extra={**found.subject, **_error_fields(error)}, + ) + return CallReportStatus.NOT_WRITTEN + return _REPORT_STATUSES[recorded] + + def _reported_call( + self, report: CallEndedReport + ) -> CallReportStatus | _CallHandlers: + """Return how to record the reported call, or why it cannot be yet.""" + call_id = report.modal_call_id + if report.simulation_run_ids is not None: + runs = [ + self.runs.get_simulation_run(run_id) + for run_id in report.simulation_run_ids + ] + known = [run for run in runs if run is not None] + if len(known) != len(runs): + return CallReportStatus.UNKNOWN_RUNS + state = _reported_state( + call_id, [(run.modal_call_id, run.status) for run in known] + ) + if state is not None: + return state + due = DueSimulationCall(modal_call_id=call_id, runs=tuple(known)) + return self._simulation_handlers(due)[1] + report_run = ( + None + if report.report_run_id is None + else self.runs.get_report_run(report.report_run_id) + ) + if report_run is None or report_run.comparison_status is None: + return CallReportStatus.UNKNOWN_RUNS + state = _reported_state( + call_id, + [(report_run.comparison_modal_call_id, report_run.comparison_status)], + ) + if state is not None: + return state + return self._comparison_handlers(report_run)[1] - async def _record( - self, outcome: CallOutcome, handlers: _CallHandlers, recording: RecordingPass - ) -> None: + async def _record(self, outcome: CallOutcome, handlers: _CallHandlers) -> _Recorded: # A call still running, or of a kind with no recorder here, is checked # again once its claim's lease ends; nothing is written. if isinstance(outcome, CallRunning): - recording.still_running += 1 - return + return _Recorded.STILL_RUNNING if isinstance(outcome, CallFailed): - await self._fail(handlers, outcome.error, recording) - return + return await self._fail(handlers, outcome.error) steps = handlers.result_steps if steps is None: # A revision without a recorder for this kind leaves the call for # one that has it; Modal's output retention bounds the wait. - recording.without_recorder += 1 logger.warning( _event_name(handlers.event_prefix, "without_result_recorder"), extra=handlers.subject, ) - return + return _Recorded.WITHOUT_RECORDER + return await self._record_result(outcome.output, steps, handlers) + + async def _record_result( + self, output: object, steps: _ResultSteps, handlers: _CallHandlers + ) -> _Recorded: + """Validate a finished call's output and write it with its success.""" try: - result = await asyncio.to_thread(steps.validate, outcome.output) + result = await asyncio.to_thread(steps.validate, output) except ValueError as error: - await self._fail(handlers, _invalid_result(error), recording) - return + return await self._fail(handlers, _invalid_result(error)) try: completed = await asyncio.to_thread(steps.complete, result) # Writing the result rows failed, for example because the database - # was unreachable: nothing was recorded, and the lease makes the call - # due again. + # was unreachable: nothing was recorded, and the call is checked again. except Exception as error: - recording.not_reached += 1 logger.exception( _event_name(handlers.event_prefix, "result_not_written"), - extra={**handlers.subject, "error_type": type(error).__name__}, - ) - return - if completed: - recording.succeeded += 1 - logger.info( - _event_name(handlers.event_prefix, "succeeded"), extra=handlers.subject + extra={**handlers.subject, **_error_fields(error)}, ) + return _Recorded.NOT_WRITTEN + if not completed: + return _Recorded.UNCHANGED + logger.info( + _event_name(handlers.event_prefix, "succeeded"), extra=handlers.subject + ) + return _Recorded.SUCCEEDED + + async def _fail(self, handlers: _CallHandlers, error: RunError) -> _Recorded: + if not await asyncio.to_thread(handlers.fail, error): + return _Recorded.UNCHANGED + logger.warning( + _event_name(handlers.event_prefix, "failed"), + extra={**handlers.subject, "error_code": error.code.value}, + ) + return _Recorded.FAILED - async def _fail( - self, handlers: _CallHandlers, error: RunError, recording: RecordingPass - ) -> None: - if await asyncio.to_thread(handlers.fail, error): - recording.failed += 1 - logger.warning( - _event_name(handlers.event_prefix, "failed"), - extra={**handlers.subject, "error_code": error.code.value}, - ) + +def _reported_state( + modal_call_id: str, executions: list[tuple[str | None, RunStatus]] +) -> CallReportStatus | None: + """Return why a reported call cannot be recorded, or None if it can. + + ``executions`` holds each reported run's (or the comparison step's) call + ID and status. + """ + held_call_ids = {held for held, _ in executions} + if held_call_ids == {modal_call_id}: + if any(status is RunStatus.RUNNING for _, status in executions): + return None + return CallReportStatus.ALREADY_RECORDED + if held_call_ids - {modal_call_id, None}: + return CallReportStatus.OTHER_CALL + # Some runs hold no call ID yet. While they are being dispatched, the + # dispatcher is about to store it; a finished run without it (failed as + # abandoned, say) never will. + if all( + status in _NOT_YET_DISPATCHED for held, status in executions if held is None + ): + return CallReportStatus.NOT_DISPATCHED_YET + return CallReportStatus.OTHER_CALL + + +def _error_fields(error: BaseException) -> dict[str, str]: + """Name an error, and the error it was raised from, for a log record.""" + fields = {"error_type": type(error).__name__} + if error.__cause__ is not None: + fields["error_cause_type"] = type(error.__cause__).__name__ + return fields diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index de01244a9..acee770ed 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -74,6 +74,7 @@ class FakeRunRecord: simulation_kind: SimulationKind modal_call_id: str | None check_count: int + status: RunStatus = RunStatus.RUNNING @dataclass(frozen=True) @@ -84,6 +85,7 @@ class FakeReportRunRecord: report_kind: str comparison_modal_call_id: str | None comparison_check_count: int + comparison_status: RunStatus | None = RunStatus.RUNNING class FakeRunTransitions: @@ -247,6 +249,34 @@ def comparison_holds_call(self, report_run_id: UUID, modal_call_id: str) -> bool with self.lock: return self.comparisons[report_run_id].modal_call_id == modal_call_id + # --- Reading ------------------------------------------------------- + + def get_simulation_run(self, run_id: UUID) -> FakeRunRecord | None: + """Return the run as a record, or None when there is no such run.""" + run = self.runs.get(run_id) + if run is None: + return None + return FakeRunRecord( + id=run.id, + simulation_kind=SimulationKind(run.kind), + modal_call_id=run.modal_call_id, + check_count=run.check_count, + status=run.status, + ) + + def get_report_run(self, report_run_id: UUID) -> FakeReportRunRecord | None: + """Return the comparison step's report run, or None when there is none.""" + comparison = self.comparisons.get(report_run_id) + if comparison is None: + return None + return FakeReportRunRecord( + id=comparison.id, + report_kind=comparison.kind, + comparison_modal_call_id=comparison.modal_call_id, + comparison_check_count=comparison.check_count, + comparison_status=comparison.status, + ) + # --- Recording ------------------------------------------------------- def claim_due_simulation_calls( diff --git a/projects/policyengine-simulation-entry/tests/test_call_report_recording.py b/projects/policyengine-simulation-entry/tests/test_call_report_recording.py new file mode 100644 index 000000000..76ef79bd0 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_call_report_recording.py @@ -0,0 +1,280 @@ +"""Recording a call's outcome when its Modal function reports that it ended.""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import pytest +from policyengine_simulation_contract.call_reports import ( + CallEndedReport, + CallReportStatus, +) +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallInspectionError, + CallSucceeded, +) +from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder +from policyengine_simulation_persistence import ( + ReportKind, + RunError, + RunErrorCode, + RunStatus, + SimulationKind, +) +from run_fakes import FakeInspector, FakeRunTransitions, ManualClock +from test_run_recording import RecordingRecorder + +WORKER_TIMEOUT = RunError(RunErrorCode.WORKER_TIMEOUT, "The Modal call timed out.") + + +def _recorder(recorders=None): + clock = ManualClock() + transitions = FakeRunTransitions(clock) + inspector = FakeInspector() + recorder = RunRecorder( + transitions=transitions, + runs=transitions, + inspector=inspector, + recorders=recorders or ResultRecorders(), + clock=clock, + ) + return recorder, transitions, inspector + + +def _economy_recorder(): + budget = RecordingRecorder() + recorder, transitions, inspector = _recorder( + ResultRecorders(simulations={SimulationKind.ECONOMY: budget}) + ) + return recorder, transitions, inspector, budget + + +def _report(recorder, **fields) -> CallReportStatus: + return asyncio.run(recorder.record_reported_call(CallEndedReport(**fields))) + + +def test_a_finished_call_is_recorded_when_it_reports() -> None: + recorder, transitions, inspector, budget = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + inspector.outcomes["fc-done"] = CallSucceeded({"budgetary_impact": 2.5}) + + status = _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.RECORDED + assert transitions.runs[run_id].status is RunStatus.SUCCEEDED + assert len(budget.writes) == 1 + + +def test_a_repeated_report_changes_nothing() -> None: + recorder, transitions, inspector, budget = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + inspector.outcomes["fc-done"] = CallSucceeded({"budgetary_impact": 2.5}) + _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + status = _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.ALREADY_RECORDED + assert len(budget.writes) == 1 + assert inspector.inspected == ["fc-done"] + + +def test_a_household_pair_is_recorded_from_one_report() -> None: + household = RecordingRecorder() + recorder, transitions, inspector = _recorder( + ResultRecorders(simulations={SimulationKind.HOUSEHOLD: household}) + ) + pair = transitions.running_call("fc-pair", "household", "household") + inspector.outcomes["fc-pair"] = CallSucceeded({"budgetary_impact": 0}) + + status = _report(recorder, modal_call_id="fc-pair", simulation_run_ids=tuple(pair)) + + assert status is CallReportStatus.RECORDED + assert {transitions.runs[run_id].status for run_id in pair} == {RunStatus.SUCCEEDED} + assert len(household.writes) == 1 + + +def test_a_failed_call_is_recorded_as_failed() -> None: + recorder, transitions, inspector = _recorder() + (run_id,) = transitions.running_call("fc-failed") + inspector.outcomes["fc-failed"] = CallFailed(WORKER_TIMEOUT) + + status = _report(recorder, modal_call_id="fc-failed", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.RECORDED + assert transitions.runs[run_id].status is RunStatus.FAILED + assert transitions.runs[run_id].error.code is RunErrorCode.WORKER_TIMEOUT + + +def test_a_call_modal_still_runs_is_left_for_another_report() -> None: + recorder, transitions, _, _ = _economy_recorder() + (run_id,) = transitions.running_call("fc-running") + next_check_at = transitions.runs[run_id].next_check_at + + status = _report(recorder, modal_call_id="fc-running", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.STILL_RUNNING + assert transitions.runs[run_id].status is RunStatus.RUNNING + assert transitions.runs[run_id].next_check_at == next_check_at + + +def test_a_call_whose_id_is_not_stored_yet_is_left_for_another_report() -> None: + """A fast household call can report before its dispatcher stores its ID.""" + recorder, transitions, inspector, _ = _economy_recorder() + run_id = transitions.add_run(status=RunStatus.DISPATCHING) + + status = _report(recorder, modal_call_id="fc-fast", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.NOT_DISPATCHED_YET + assert inspector.inspected == [] + assert transitions.runs[run_id].status is RunStatus.DISPATCHING + + +def test_a_report_naming_an_unknown_run_is_refused() -> None: + recorder, transitions, inspector, _ = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + + status = _report( + recorder, modal_call_id="fc-done", simulation_run_ids=(run_id, uuid4()) + ) + + assert status is CallReportStatus.UNKNOWN_RUNS + assert inspector.inspected == [] + + +@pytest.mark.parametrize( + "run_state", + [ + {"status": RunStatus.RUNNING, "modal_call_id": "fc-other"}, + {"status": RunStatus.SUCCEEDED, "modal_call_id": "fc-other"}, + # Failed as abandoned before the call's ID was stored. + {"status": RunStatus.FAILED, "modal_call_id": None}, + ], + ids=["running-another-call", "finished-another-call", "failed-without-call"], +) +def test_a_report_for_runs_that_do_not_hold_its_call_is_refused(run_state) -> None: + recorder, transitions, inspector, _ = _economy_recorder() + run_id = transitions.add_run(**run_state) + + status = _report(recorder, modal_call_id="fc-late", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.OTHER_CALL + assert inspector.inspected == [] + assert transitions.runs[run_id].status is run_state["status"] + + +def test_an_unreachable_modal_is_left_for_another_report() -> None: + recorder, transitions, inspector, _ = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + inspector.outcomes["fc-done"] = CallInspectionError("ConnectionError") + + status = _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.MODAL_UNREACHABLE + assert transitions.runs[run_id].status is RunStatus.RUNNING + + +def test_a_kind_without_a_recorder_is_left_for_a_revision_with_one() -> None: + recorder, transitions, inspector = _recorder() + (run_id,) = transitions.running_call("fc-done") + inspector.outcomes["fc-done"] = CallSucceeded({"budgetary_impact": 1}) + + status = _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.NO_RESULT_RECORDER + assert transitions.runs[run_id].status is RunStatus.RUNNING + + +def test_a_failed_read_is_left_for_another_report() -> None: + recorder, transitions, inspector, _ = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + + def unreachable_database(_run_id): + msg = "connection lost" + raise ConnectionError(msg) + + transitions.get_simulation_run = unreachable_database # type: ignore[method-assign] + + status = _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.NOT_WRITTEN + assert inspector.inspected == [] + + +@pytest.mark.parametrize( + ("outcome", "write"), + [ + (CallSucceeded({"budgetary_impact": 2.5}), "complete_simulation_call"), + (CallFailed(WORKER_TIMEOUT), "fail_simulation_call"), + ], + ids=["result", "failure"], +) +def test_a_failed_write_is_left_for_another_report(outcome, write) -> None: + recorder, transitions, inspector, _ = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + inspector.outcomes["fc-done"] = outcome + + def unreachable_database(*_arguments): + msg = "connection lost" + raise ConnectionError(msg) + + setattr(transitions, write, unreachable_database) + + status = _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + assert status is CallReportStatus.NOT_WRITTEN + assert transitions.runs[run_id].status is RunStatus.RUNNING + + +def test_a_signal_to_this_process_is_not_swallowed() -> None: + recorder, transitions, inspector, _ = _economy_recorder() + (run_id,) = transitions.running_call("fc-done") + inspector.outcomes["fc-done"] = CallFailed(WORKER_TIMEOUT) + + def interrupted(*_arguments): + raise KeyboardInterrupt + + transitions.fail_simulation_call = interrupted # type: ignore[method-assign] + + with pytest.raises(KeyboardInterrupt): + _report(recorder, modal_call_id="fc-done", simulation_run_ids=(run_id,)) + + +def test_a_comparison_call_is_recorded_when_it_reports() -> None: + comparisons = RecordingRecorder() + recorder, transitions, inspector = _recorder( + ResultRecorders(comparisons={ReportKind.ECONOMY: comparisons}) + ) + report_run_id = transitions.add_comparison( + status=RunStatus.RUNNING, modal_call_id="fc-comparison" + ) + inspector.outcomes["fc-comparison"] = CallSucceeded({"budgetary_impact": 3}) + + status = _report( + recorder, modal_call_id="fc-comparison", report_run_id=report_run_id + ) + + assert status is CallReportStatus.RECORDED + assert transitions.comparisons[report_run_id].status is RunStatus.SUCCEEDED + assert [record.id for _, record, _ in comparisons.writes] == [report_run_id] + + +def test_a_comparison_report_for_an_unknown_report_run_is_refused() -> None: + recorder, _, inspector, _ = _economy_recorder() + + status = _report(recorder, modal_call_id="fc-comparison", report_run_id=uuid4()) + + assert status is CallReportStatus.UNKNOWN_RUNS + assert inspector.inspected == [] + + +def test_a_comparison_whose_call_id_is_not_stored_yet_waits() -> None: + recorder, transitions, _, _ = _economy_recorder() + report_run_id = transitions.add_comparison(status=RunStatus.DISPATCHING) + + status = _report( + recorder, modal_call_id="fc-comparison", report_run_id=report_run_id + ) + + assert status is CallReportStatus.NOT_DISPATCHED_YET diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index a47f93b73..f646cfca9 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -64,6 +64,7 @@ def _recorder(recorders=None): inspector = FakeInspector() recorder = RunRecorder( transitions=transitions, + runs=transitions, inspector=inspector, recorders=recorders or ResultRecorders(), clock=clock, From 25cd7a5f1664e7c2728872ad1fc49a9dc21504e9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:14:23 +0400 Subject: [PATCH 069/124] Accept call-ended reports from sim's call-report Modal function POST /internal/modal-calls/ended, not in the public OpenAPI document, takes a CallEndedReport with the Modal identity token of the report_call_ended function as a bearer token. The token is checked first (ModalIdentityVerifier, built from the expected Modal caller), so a caller without it gets 401 and learns nothing about the body; then the body is parsed (422 when it is not a report) and the outcome recorded with RunRecorder.record_reported_call, which SimulationDatabase now builds. No caller permission applies: the Modal token is the only proof. The answer is a CallReportAnswer: 200 for recorded or already_recorded; 503 with Retry-After for a status the sender should report again after (including unreachable Modal keys, and an instance without the expected Modal caller or a database); 404 for unknown runs and 409 for runs that hold another call, which the sender does not repeat. create_app's database seam and the route's two seams are grouped as RunServices, so the factory keeps five arguments. Co-Authored-By: Claude Opus 5.5 --- .../src/policyengine_simulation_entry/app.py | 37 +++- .../call_report_route.py | 147 ++++++++++++++ .../simulation_database.py | 19 ++ .../tests/test_call_report_route.py | 181 ++++++++++++++++++ .../tests/test_run_recording.py | 4 +- .../tests/test_simulation_database.py | 4 +- 6 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py create mode 100644 projects/policyengine-simulation-entry/tests/test_call_report_route.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index d862ebcd1..9db5f312c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -10,6 +10,7 @@ import logging import uuid from contextlib import asynccontextmanager +from dataclasses import dataclass from typing import TYPE_CHECKING from uuid import UUID @@ -51,6 +52,11 @@ CallerAuthorizer, ) from policyengine_simulation_entry.backend import OldGatewayBackend, SimulationBackend +from policyengine_simulation_entry.call_report_route import ( + CallReportRecorder, + ModalCallerVerifier, + register_call_report_route, +) from policyengine_simulation_entry.config import Settings from policyengine_simulation_entry.entry_runtime import ( BACKEND_RESPONSE_HEADER, @@ -65,6 +71,7 @@ route_template, set_observability_header, ) +from policyengine_simulation_entry.modal_identity import ModalIdentityVerifier from policyengine_simulation_entry.schemas import ( SimulationDatabaseReadiness, SimulationEntrypointReadinessResponse, @@ -88,6 +95,7 @@ "STAGE12_MODAL_SUBMISSION_TIMEOUT_SECONDS", "ComparisonBackend", "ResponseIdentifier", + "RunServices", "SimulationDatabaseConnection", "app", "create_app", @@ -97,6 +105,20 @@ SIMULATION_DATABASE_READINESS_TIMEOUT_SECONDS = 3.0 +@dataclass(frozen=True) +class RunServices: + """The simulation database and the call-ended route's seams, for tests. + + ``create_app`` builds each one it is not given: the database from + ``SIMULATION_DATABASE_URL``, the call recorder from that database, and the + Modal identity verifier from the expected Modal caller. + """ + + database: SimulationDatabaseConnection | None = None + call_recorder: CallReportRecorder | None = None + modal_identity_verifier: ModalCallerVerifier | None = None + + def _temporary_submission_payload( report: ComparisonReportRecord, ) -> TemporaryStage12SubmissionResponse: @@ -558,15 +580,23 @@ def create_app( backend: SimulationBackend | None = None, auth_dependency: AuthenticationDependency | None = None, comparison_backend: ComparisonBackend | None = None, - simulation_database: SimulationDatabaseConnection | None = None, + run_services: RunServices | None = None, ) -> FastAPI: """Build the app with injectable auth/backend seams for hermetic tests.""" runtime_settings = settings or Settings.from_env() - runtime_database = simulation_database + services = run_services or RunServices() + runtime_database = services.database if runtime_database is None and runtime_settings.simulation_database_url: runtime_database = SimulationDatabase.from_url( runtime_settings.simulation_database_url ) + call_recorder = services.call_recorder + if call_recorder is None and isinstance(runtime_database, SimulationDatabase): + call_recorder = runtime_database.call_recorder() + modal_identity_verifier = services.modal_identity_verifier + expected_modal_caller = runtime_settings.expected_modal_caller + if modal_identity_verifier is None and expected_modal_caller is not None: + modal_identity_verifier = ModalIdentityVerifier(expected_modal_caller) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -629,6 +659,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: reader=simulation_reader, ) _register_service_routes(app, entry) + register_call_report_route( + app, verifier=modal_identity_verifier, recorder=call_recorder + ) return app diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py new file mode 100644 index 000000000..1673b25cd --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py @@ -0,0 +1,147 @@ +"""The route on which sim's call-report Modal function reports that a call ended. + +``POST /internal/modal-calls/ended`` (``CALL_ENDED_PATH``), not in the public +OpenAPI document. The request carries the Modal identity token of the +``report_call_ended`` function as a bearer token, and a ``CallEndedReport`` +naming the call and its runs, never a result. The token is checked first, so +a caller without it learns nothing about the body. Then the reported call's +outcome is recorded, if Modal has one (``RunRecorder.record_reported_call``). + +Answers, each a ``CallReportAnswer``: + +- 200: ``recorded`` or ``already_recorded``; a repeated report changes + nothing. +- 503 with ``Retry-After``: a status in ``RETRY_LATER_STATUSES``; the sender + reports again after a wait, and the safety check covers a call whose + sender gives up. +- 404 ``unknown_runs`` and 409 ``other_call``: the report cannot be recorded; + the sender stops. +- 401: the token is missing or refused; 422: the body is not a report. + +Only the revision that serves traffic receives these requests, since the +sender posts to sim's main URL. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Protocol + +from fastapi import Request, status +from fastapi.responses import JSONResponse +from policyengine_simulation_contract.call_reports import ( + CALL_ENDED_PATH, + CallEndedReport, + CallReportAnswer, + CallReportStatus, +) +from pydantic import ValidationError + +from policyengine_simulation_entry.modal_identity import ( + ModalIdentityError, + ModalKeysUnavailableError, +) + +if TYPE_CHECKING: + from fastapi import FastAPI + + from policyengine_simulation_entry.modal_identity import ModalIdentity + +logger = logging.getLogger(__name__) + +# How long the sender waits before reporting again, in seconds. +RETRY_AFTER_SECONDS = 5 +_REFUSED_STATUSES = { + CallReportStatus.UNKNOWN_RUNS: status.HTTP_404_NOT_FOUND, + CallReportStatus.OTHER_CALL: status.HTTP_409_CONFLICT, +} +_BEARER_PREFIX = "bearer " + + +class CallReportRecorder(Protocol): + """Record a reported call's outcome (``RunRecorder``).""" + + async def record_reported_call(self, report: CallEndedReport) -> CallReportStatus: + """Record the call's outcome if Modal has one, and say what was done.""" + ... + + +class ModalCallerVerifier(Protocol): + """Check a Modal identity token (``ModalIdentityVerifier``).""" + + def verify(self, token: str) -> ModalIdentity: + """Return the token's identity, or raise ModalIdentityError.""" + ... + + +def _answer(report_status: CallReportStatus) -> JSONResponse: + answer = CallReportAnswer(status=report_status) + if answer.retry_later: + return JSONResponse( + answer.model_dump(mode="json"), + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + headers={"Retry-After": str(RETRY_AFTER_SECONDS)}, + ) + return JSONResponse( + answer.model_dump(mode="json"), + status_code=_REFUSED_STATUSES.get(report_status, status.HTTP_200_OK), + ) + + +def _unauthorized() -> JSONResponse: + return JSONResponse( + {"detail": "A Modal identity token of the call-report function is required."}, + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _bearer_token(request: Request) -> str | None: + header = request.headers.get("authorization", "") + if not header.lower().startswith(_BEARER_PREFIX): + return None + return header[len(_BEARER_PREFIX) :].strip() or None + + +def register_call_report_route( + app: FastAPI, + *, + verifier: ModalCallerVerifier | None, + recorder: CallReportRecorder | None, +) -> None: + """Add the call-ended route; without both seams it answers 503.""" + + @app.post(CALL_ENDED_PATH, include_in_schema=False) + async def report_call_ended(request: Request) -> JSONResponse: + if verifier is None or recorder is None: + return _answer(CallReportStatus.NOT_CONFIGURED) + token = _bearer_token(request) + if token is None: + return _unauthorized() + try: + identity = await asyncio.to_thread(verifier.verify, token) + except ModalKeysUnavailableError: + logger.warning("call_report_modal_keys_unavailable") + return _answer(CallReportStatus.MODAL_UNREACHABLE) + except ModalIdentityError as error: + logger.warning("call_report_refused", extra={"reason": str(error)}) + return _unauthorized() + try: + report = CallEndedReport.model_validate_json(await request.body()) + except ValidationError as error: + return JSONResponse( + {"detail": error.errors(include_url=False, include_input=False)}, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + report_status = await recorder.record_reported_call(report) + logger.info( + "call_report_answered", + extra={ + "modal_call_id": report.modal_call_id, + "call_report_status": report_status.value, + "modal_app_name": identity.app_name, + "modal_container_id": identity.container_id, + }, + ) + return _answer(report_status) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index e0ca4f1bd..4401f8b7b 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -6,10 +6,15 @@ from typing import TYPE_CHECKING from policyengine_simulation_persistence import ( + RunStore, + RunTransitions, create_simulation_engine, read_schema_revision, ) +from policyengine_simulation_entry.modal_calls import ModalCallInspector +from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder + if TYPE_CHECKING: from sqlalchemy import Engine @@ -37,6 +42,20 @@ async def read_revision(self) -> str | None: """Return the applied migration revision, or None before the first one.""" return await asyncio.to_thread(self._read_revision) + def call_recorder(self) -> RunRecorder: + """Return what records a reported call's outcome on its runs. + + No kind of run has a result recorder yet (stage 2 registers them), so + a finished call is answered no_result_recorder and left for the + safety check; no route creates runs yet. + """ + return RunRecorder( + transitions=RunTransitions(self.engine), + runs=RunStore(self.engine), + inspector=ModalCallInspector(), + recorders=ResultRecorders(), + ) + def dispose(self) -> None: """Close every pooled connection.""" self.engine.dispose() diff --git a/projects/policyengine-simulation-entry/tests/test_call_report_route.py b/projects/policyengine-simulation-entry/tests/test_call_report_route.py new file mode 100644 index 000000000..326d4dbf2 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_call_report_route.py @@ -0,0 +1,181 @@ +"""The route on which sim's call-report Modal function reports that a call ended.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from uuid import uuid4 + +import pytest +from conftest import FakeBackend, make_settings +from fastapi import HTTPException +from fastapi.testclient import TestClient +from policyengine_simulation_contract.call_reports import ( + CALL_ENDED_PATH, + RETRY_LATER_STATUSES, + CallEndedReport, + CallReportStatus, +) +from policyengine_simulation_entry.app import RunServices, create_app +from policyengine_simulation_entry.modal_identity import ( + ModalIdentity, + ModalIdentityError, + ModalKeysUnavailableError, +) + +GOOD_TOKEN = "token-of-the-call-report-function" +RUN_ID = uuid4() +REPORT = {"modal_call_id": "fc-ended", "simulation_run_ids": [str(RUN_ID)]} + + +@dataclass +class _Verifier: + """Accept one token; refuse or fail on the others.""" + + keys_unavailable: bool = False + verified: list[str] = field(default_factory=list) + + def verify(self, token: str) -> ModalIdentity: + self.verified.append(token) + if self.keys_unavailable: + raise ModalKeysUnavailableError + if token != GOOD_TOKEN: + msg = "the token names another Modal function" + raise ModalIdentityError(msg) + return ModalIdentity( + app_name="policyengine-simulation-v2-py6-10-0", + function_name="report_call_ended", + container_id="ta-container0001", + ) + + +@dataclass +class _Recorder: + """Answer every report with one status, and keep the reports.""" + + status: CallReportStatus = CallReportStatus.RECORDED + reports: list[CallEndedReport] = field(default_factory=list) + + async def record_reported_call(self, report: CallEndedReport) -> CallReportStatus: + self.reports.append(report) + return self.status + + +def _reject_every_caller(): + raise HTTPException(status_code=403) + + +def _client(verifier=None, recorder=None) -> TestClient: + return TestClient( + create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=_reject_every_caller, + run_services=RunServices( + call_recorder=recorder, modal_identity_verifier=verifier + ), + ) + ) + + +def _post(client, body=REPORT, token=GOOD_TOKEN): + headers = {} if token is None else {"Authorization": f"Bearer {token}"} + return client.post(CALL_ENDED_PATH, json=body, headers=headers) + + +def test_a_report_from_the_call_report_function_is_recorded() -> None: + verifier, recorder = _Verifier(), _Recorder() + + response = _post(_client(verifier, recorder)) + + assert response.status_code == 200 + assert response.json() == {"status": "recorded"} + assert verifier.verified == [GOOD_TOKEN] + assert recorder.reports == [CallEndedReport.model_validate(REPORT)] + + +def test_a_repeated_report_is_answered_as_already_recorded() -> None: + recorder = _Recorder(status=CallReportStatus.ALREADY_RECORDED) + + response = _post(_client(_Verifier(), recorder)) + + assert response.status_code == 200 + assert response.json() == {"status": "already_recorded"} + + +@pytest.mark.parametrize("status", sorted(RETRY_LATER_STATUSES - {"not_configured"})) +def test_a_report_sim_cannot_record_yet_is_answered_with_a_wait(status) -> None: + response = _post(_client(_Verifier(), _Recorder(status=CallReportStatus(status)))) + + assert response.status_code == 503 + assert response.json() == {"status": status} + assert int(response.headers["Retry-After"]) > 0 + + +@pytest.mark.parametrize( + ("status", "http_status"), + [(CallReportStatus.UNKNOWN_RUNS, 404), (CallReportStatus.OTHER_CALL, 409)], +) +def test_a_report_that_cannot_be_recorded_is_refused(status, http_status) -> None: + response = _post(_client(_Verifier(), _Recorder(status=status))) + + assert response.status_code == http_status + assert response.json() == {"status": status.value} + assert "Retry-After" not in response.headers + + +@pytest.mark.parametrize("token", [None, "token-of-a-policy-worker"]) +def test_a_request_without_the_functions_token_is_refused(token) -> None: + recorder = _Recorder() + + response = _post(_client(_Verifier(), recorder), token=token) + + assert response.status_code == 401 + assert recorder.reports == [] + + +def test_an_unauthenticated_request_learns_nothing_about_its_body() -> None: + response = _post(_client(_Verifier(), _Recorder()), body={"x": 1}, token=None) + + assert response.status_code == 401 + + +def test_a_malformed_report_is_refused_after_authentication() -> None: + recorder = _Recorder() + + response = _post( + _client(_Verifier(), recorder), + body={"modal_call_id": "fc-ended", "simulation_run_ids": [], "result": {}}, + ) + + assert response.status_code == 422 + assert recorder.reports == [] + + +def test_unreachable_modal_keys_are_answered_with_a_wait() -> None: + recorder = _Recorder() + + response = _post(_client(_Verifier(keys_unavailable=True), recorder)) + + assert response.status_code == 503 + assert response.json() == {"status": "modal_unreachable"} + assert recorder.reports == [] + + +@pytest.mark.parametrize( + ("verifier", "recorder"), + [(None, _Recorder()), (_Verifier(), None)], + ids=["no-modal-caller", "no-database"], +) +def test_an_instance_not_configured_for_reports_answers_with_a_wait( + verifier, recorder +) -> None: + response = _post(_client(verifier, recorder)) + + assert response.status_code == 503 + assert response.json() == {"status": "not_configured"} + + +def test_the_route_is_not_in_the_public_openapi_document() -> None: + document = _client(_Verifier(), _Recorder()).get("/openapi.json").json() + + assert CALL_ENDED_PATH not in document["paths"] diff --git a/projects/policyengine-simulation-entry/tests/test_run_recording.py b/projects/policyengine-simulation-entry/tests/test_run_recording.py index f646cfca9..a2408ea8a 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -10,7 +10,7 @@ from conftest import FakeBackend, make_settings from fastapi.testclient import TestClient from policyengine_simulation_entry import run_recording -from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.app import RunServices, create_app from policyengine_simulation_entry.modal_calls import ( CallFailed, CallInspectionError, @@ -396,7 +396,7 @@ def test_an_instance_runs_nothing_in_the_background() -> None: ), backend=FakeBackend(), auth_dependency=lambda: None, - simulation_database=_DatabaseThatOnlyReadsItsRevision(), + run_services=RunServices(database=_DatabaseThatOnlyReadsItsRevision()), ) with TestClient(app) as client: diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_database.py b/projects/policyengine-simulation-entry/tests/test_simulation_database.py index 95b1defae..c5fd00f91 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_database.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from policyengine_simulation_entry import app as app_module from policyengine_simulation_entry import simulation_database as database_module -from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.app import RunServices, create_app from policyengine_simulation_entry.simulation_database import SimulationDatabase from policyengine_simulation_persistence import HEAD_REVISION from sqlalchemy.exc import OperationalError @@ -43,7 +43,7 @@ def _client(backend, database): settings=make_settings(simulation_database_url=DATABASE_URL), backend=backend, auth_dependency=lambda: None, - simulation_database=database, + run_services=RunServices(database=database), ) ) From 995851570244b5bea75122492c164e225d59adfb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:17:03 +0400 Subject: [PATCH 070/124] Add the safety check: one recording pass per execution of a job A Modal call reports when it ends; the safety check covers a call whose report never arrived. python -m policyengine_simulation_entry.safety_check runs one RunRecorder pass and exits: it fails runs and comparison steps left dispatching for ten minutes, and records what Modal reports for running calls dispatched, or last checked, at least one interval ago. It exits 0 after a pass, 1 when the pass raised (the next execution tries again) and 2 without SIMULATION_DATABASE_URL; only a signal propagates. SAFETY_CHECK_INTERVAL (five minutes) is the schedule's interval, the claim's lease, so each execution checks a running call once, and the wait before a new call's first check, which the dispatcher now sets. Co-Authored-By: Claude Opus 5.5 --- .../run_dispatch.py | 8 +- .../run_recording.py | 11 +- .../safety_check.py | 90 ++++++++++++++ .../tests/test_safety_check.py | 110 ++++++++++++++++++ 4 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py create mode 100644 projects/policyengine-simulation-entry/tests/test_safety_check.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index 7bb3524ea..af392ffd8 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -17,13 +17,14 @@ import asyncio import logging from dataclasses import dataclass -from datetime import timedelta from enum import StrEnum from typing import TYPE_CHECKING, Protocol from policyengine_simulation_persistence import RunError, RunErrorCode from policyengine_simulation_persistence.run_store import utc_now +from policyengine_simulation_entry.run_recording import SAFETY_CHECK_INTERVAL + if TYPE_CHECKING: from collections.abc import Callable, Collection from datetime import datetime @@ -39,8 +40,9 @@ def _event_name(prefix: str, outcome: str) -> str: return f"{prefix}_{outcome}" -# The first check of a new call; a recording pass backs off from there. -FIRST_CHECK_DELAY = timedelta(seconds=2) +# A call reports when it ends; the safety check first looks at one that has +# not reported after this long. +FIRST_CHECK_DELAY = SAFETY_CHECK_INTERVAL class DispatchOutcome(StrEnum): diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 72a64d83d..395f23153 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -68,9 +68,14 @@ def _event_name(prefix: str, outcome: str) -> str: return f"{prefix}_{outcome}" -# A claimed call is not checked by another instance for this long; if the -# claiming instance stops, the call becomes due again afterwards. -CHECK_LEASE = timedelta(minutes=1) +# Cloud Scheduler starts the safety check this often (the deploy's +# schedule): a call that has not reported is checked about this long after +# its dispatch, then once per interval while it runs. +SAFETY_CHECK_INTERVAL = timedelta(minutes=5) +# A claimed call is not checked by another pass for this long, so each +# execution of the safety check checks a running call once; if the pass +# stops, the call becomes due again afterwards. +CHECK_LEASE = SAFETY_CHECK_INTERVAL # A run left dispatching this long was claimed by an instance that stopped. DISPATCH_ABANDONED_AFTER = timedelta(minutes=10) CLAIM_BATCH_SIZE = 50 diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py new file mode 100644 index 000000000..378b123ef --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py @@ -0,0 +1,90 @@ +"""The safety check: one recording pass, run as a Cloud Run job, then exit. + +A Modal call reports when it ends, and sim records its outcome then +(``call_report_route``). The safety check covers a call whose report never +arrived: Cloud Scheduler starts this job every ``SAFETY_CHECK_INTERVAL``, and +each execution runs one ``RunRecorder`` pass, which + +- fails runs and comparison steps left ``dispatching`` for ten minutes (sim + stopped between claiming them and recording their Modal call), so they can + be submitted again, and pending comparison steps whose run failed; +- asks Modal about running calls first dispatched, or last checked, at least + one interval ago, and records what it reports, with the same conditional + writes as a report. + +The job runs only the image of the revision that serves traffic: the deploy +updates it when that revision is promoted, and a rollback moves it back, so +a rejected candidate never records anything. Run it with +``python -m policyengine_simulation_entry.safety_check``; it reads +``SIMULATION_DATABASE_URL`` and sim's Modal token (``MODAL_TOKEN_ID``, +``MODAL_TOKEN_SECRET``). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from dataclasses import asdict +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_entry.modal_calls import OWN_PROCESS_SIGNALS +from policyengine_simulation_entry.simulation_database import SimulationDatabase + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from policyengine_simulation_entry.run_recording import RecordingPass + +logger = logging.getLogger(__name__) + +EXIT_PASSED = 0 +EXIT_PASS_FAILED = 1 +EXIT_NOT_CONFIGURED = 2 + + +class DueOutcomeRecorder(Protocol): + """Run one recording pass (``RunRecorder``).""" + + async def record_due_outcomes(self) -> RecordingPass: + """Fail abandoned dispatches, then check every due call.""" + ... + + +def _database_recorder(database_url: str) -> DueOutcomeRecorder: + return SimulationDatabase.from_url(database_url).call_recorder() + + +def main( + *, + environment: Mapping[str, str] | None = None, + build_recorder: Callable[[str], DueOutcomeRecorder] = _database_recorder, +) -> int: + """Run one pass and return the process exit status. + + Only a signal to this process propagates; any other error fails the + execution, and the next scheduled execution tries again. + """ + values = os.environ if environment is None else environment + database_url = values.get("SIMULATION_DATABASE_URL", "") + if not database_url: + logger.error("safety_check_not_configured") + print("SIMULATION_DATABASE_URL is not set.", file=sys.stderr) # noqa: T201 # the job's own error output + return EXIT_NOT_CONFIGURED + try: + recording = asyncio.run(build_recorder(database_url).record_due_outcomes()) + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise + logger.exception( + "safety_check_pass_failed", extra={"error_type": type(error).__name__} + ) + return EXIT_PASS_FAILED + logger.info("safety_check_pass", extra=asdict(recording)) + return EXIT_PASSED + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + sys.exit(main()) diff --git a/projects/policyengine-simulation-entry/tests/test_safety_check.py b/projects/policyengine-simulation-entry/tests/test_safety_check.py new file mode 100644 index 000000000..20810acbd --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_safety_check.py @@ -0,0 +1,110 @@ +"""The safety check: one recording pass per execution of a Cloud Run job.""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import timedelta + +import pytest +from policyengine_simulation_entry import safety_check +from policyengine_simulation_entry.run_dispatch import FIRST_CHECK_DELAY +from policyengine_simulation_entry.run_recording import ( + CHECK_LEASE, + SAFETY_CHECK_INTERVAL, + RecordingPass, +) + +DATABASE_URL = "postgresql://runtime:secret@db.example/postgres" + + +class _OnePass: + """A recorder whose pass returns a scripted result, or raises.""" + + def __init__(self, result) -> None: + self.result = result + self.passes = 0 + + async def record_due_outcomes(self) -> RecordingPass: + self.passes += 1 + if isinstance(self.result, BaseException): + raise self.result + return self.result + + +def _run(environment, recorder): + built_for = [] + + def build(database_url): + built_for.append(database_url) + return recorder + + status = safety_check.main(environment=environment, build_recorder=build) + return status, built_for + + +def test_each_execution_runs_one_pass_and_exits() -> None: + recorder = _OnePass(RecordingPass(checked=4, succeeded=1, failed=1)) + + status, built_for = _run({"SIMULATION_DATABASE_URL": DATABASE_URL}, recorder) + + assert status == 0 + assert recorder.passes == 1 + assert built_for == [DATABASE_URL] + + +def test_the_pass_is_logged_with_what_it_found(caplog) -> None: + caplog.set_level("INFO") + recorder = _OnePass(RecordingPass(checked=4, succeeded=1, abandoned=2)) + + _run({"SIMULATION_DATABASE_URL": DATABASE_URL}, recorder) + + (record,) = [ + record for record in caplog.records if record.message == "safety_check_pass" + ] + assert record.checked == 4 + assert record.succeeded == 1 + assert record.abandoned == 2 + + +def test_an_execution_without_the_database_url_fails_without_a_pass() -> None: + recorder = _OnePass(RecordingPass()) + + status, built_for = _run({}, recorder) + + assert status == safety_check.EXIT_NOT_CONFIGURED + assert built_for == [] + + +def test_a_pass_that_raises_fails_the_execution() -> None: + status, _ = _run( + {"SIMULATION_DATABASE_URL": DATABASE_URL}, _OnePass(RuntimeError("down")) + ) + + assert status == safety_check.EXIT_PASS_FAILED + + +def test_a_signal_to_the_job_is_not_swallowed() -> None: + with pytest.raises(KeyboardInterrupt): + _run({"SIMULATION_DATABASE_URL": DATABASE_URL}, _OnePass(KeyboardInterrupt())) + + +def test_the_job_runs_the_module_as_its_command() -> None: + """The deploy runs ``python -m policyengine_simulation_entry.safety_check``.""" + result = subprocess.run( + [sys.executable, "-m", "policyengine_simulation_entry.safety_check"], + check=False, + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin"}, + ) + + assert result.returncode == safety_check.EXIT_NOT_CONFIGURED + assert "SIMULATION_DATABASE_URL" in result.stderr + + +def test_a_call_is_first_checked_one_interval_after_dispatch() -> None: + """A call reports when it ends; the safety check looks only after a while.""" + assert timedelta(minutes=5) == SAFETY_CHECK_INTERVAL + assert FIRST_CHECK_DELAY == SAFETY_CHECK_INTERVAL + assert CHECK_LEASE == SAFETY_CHECK_INTERVAL From 41315cc05d6fdaf0580811ffd4e94f41a4d3e7f3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:19:39 +0400 Subject: [PATCH 071/124] Deploy the entrypoint with the Modal caller its reports must come from The deploy step passes the beta/prod GitHub environment variable MODAL_WORKSPACE_ID to the deploy script, which requires a Modal workspace ID and writes it, with the deployment's own Modal environment (MODAL_ENVIRONMENT: staging or main), to the runtime as SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID and SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT. Every deploy stops until an operator sets the variable. The deployment notes say how the route checks a report and that Modal needs no Google credentials, since the service already allows unauthenticated requests through Cloud Run. Co-Authored-By: Claude Opus 5.5 --- .../deploy-cloud-run-simulation-entry.sh | 7 ++ .../workflows/simulation-deploy.reusable.yml | 1 + .../DEPLOYMENT.md | 13 ++++ .../tests/test_call_report_deployment.py | 76 +++++++++++++++++++ .../tests/test_deployment_assets_cloud_run.py | 1 + 5 files changed, 98 insertions(+) create mode 100644 projects/policyengine-simulation-entry/tests/test_call_report_deployment.py diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 36b1899ff..45784c03a 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -102,6 +102,7 @@ required_variables=( SIMULATION_DATABASE_URL_SECRET_NAME MODAL_TOKEN_ID_SECRET_NAME MODAL_TOKEN_SECRET_SECRET_NAME + MODAL_WORKSPACE_ID_VALUE OBSERVABILITY_SERVICE_NAMESPACE OBSERVABILITY_TRACE_PROJECT_ID OTEL_EXPORTER_OTLP_ENDPOINT @@ -128,6 +129,10 @@ esac || fail "TAG is invalid" [[ "${ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \ || fail "ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT is invalid" +# The Modal workspace whose call-report function may report that calls +# ended (the entrypoint also checks the environment, MODAL_ENVIRONMENT). +[[ "${MODAL_WORKSPACE_ID_VALUE}" =~ ^ac-[A-Za-z0-9]+$ ]] \ + || fail "MODAL_WORKSPACE_ID_VALUE must be a Modal workspace ID" [[ "${ENTRYPOINT_MIN_INSTANCES}" =~ ^[0-9]+$ ]] \ || fail "ENTRYPOINT_MIN_INSTANCES must be a non-negative integer" [[ "${ENTRYPOINT_MAX_INSTANCES}" =~ ^[0-9]+$ ]] \ @@ -191,6 +196,8 @@ jq -n ' OTEL_METRICS_EXPORTER: "otlp", OTEL_TRACES_SAMPLER_ARG: "1.0", POLICYENGINE_OTEL_GOOGLE_AUDIENCE: env.POLICYENGINE_OTEL_GOOGLE_AUDIENCE, + SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID: env.MODAL_WORKSPACE_ID_VALUE, + SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT: env.MODAL_ENVIRONMENT, STAGE12_ENABLED: ( if env.DEPLOY_STAGE12_V2 == "true" then env.STAGE12_ENABLED_VALUE else "0" end ) diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 15990f525..8d74bf019 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -273,6 +273,7 @@ jobs: STAGE12_DATABASE_URL_SECRET_NAME: ${{ vars.STAGE12_DATABASE_URL_SECRET_NAME }} MODAL_TOKEN_ID_SECRET_NAME: ${{ vars.MODAL_TOKEN_ID_SECRET_NAME }} MODAL_TOKEN_SECRET_SECRET_NAME: ${{ vars.MODAL_TOKEN_SECRET_SECRET_NAME }} + MODAL_WORKSPACE_ID_VALUE: ${{ vars.MODAL_WORKSPACE_ID }} run: .github/scripts/deploy-cloud-run-simulation-entry.sh - name: Resolve exact candidate metadata diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index dbe4d5461..140087530 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -68,6 +68,19 @@ environment variables `MODAL_TOKEN_ID_SECRET_NAME` and service account must be able to read both secrets. The Stage 12 comparison path's Modal calls use the same token until it is removed. +A Modal call reports that it ended on `POST /internal/modal-calls/ended` +(`policyengine_simulation_entry.call_report_route`), with the Modal identity +token of the `report_call_ended` function as a bearer token. The entrypoint +checks the token against Modal's published keys and accepts only that +function, in a v2 app, of the workspace in the `beta`/`prod` environment +variable `MODAL_WORKSPACE_ID` and of the deployment's own Modal environment; +the deploy passes both to the runtime as +`SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID` and +`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`, and stops when the workspace ID is +missing. The service already allows unauthenticated requests through Cloud +Run (`--allow-unauthenticated`): callers prove who they are to the +entrypoint, so Modal needs no Google credentials. + ## Candidate qualification For each eligible revision, the workflow first deploys `beta` diff --git a/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py b/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py new file mode 100644 index 000000000..9b428dfbb --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py @@ -0,0 +1,76 @@ +"""Deploy settings for the route on which Modal reports that a call ended. + +The entrypoint accepts reports only from the call-report function of one +Modal workspace and environment. The workspace ID comes from the `beta` and +`prod` GitHub environment variable `MODAL_WORKSPACE_ID`; the environment is +the deployment's own Modal environment. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest +from test_deployment_assets_cloud_run import cloud_run_deploy_environment +from test_deployment_assets_support import ( + CLOUD_RUN_DEPLOY_SCRIPT, + REUSABLE_DEPLOY_WORKFLOW, + workflow_jobs, +) + + +def _deploy(tmp_path, **overrides): + environment = cloud_run_deploy_environment(tmp_path) | overrides + result = subprocess.run( + [CLOUD_RUN_DEPLOY_SCRIPT], + check=False, + capture_output=True, + text=True, + env=environment, + ) + capture = Path(environment["GCLOUD_ENV_FILE_CAPTURE"]) + runtime = ( + json.loads(capture.read_text(encoding="utf-8")) if capture.exists() else {} + ) + return result, runtime + + +@pytest.mark.parametrize( + ("app_environment", "modal_environment"), + [("staging", "staging"), ("production", "main")], +) +def test_the_entrypoint_expects_reports_from_its_own_modal_environment( + tmp_path, app_environment, modal_environment +) -> None: + result, runtime = _deploy( + tmp_path, + APP_ENVIRONMENT=app_environment, + MODAL_ENVIRONMENT=modal_environment, + MODAL_WORKSPACE_ID_VALUE="ac-workspace0001", + ) + + assert result.returncode == 0, result.stderr + assert runtime["SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID"] == "ac-workspace0001" + assert runtime["SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT"] == modal_environment + + +@pytest.mark.parametrize("workspace_id", ["", "workspace0001", "ac-", "ac-a b"]) +def test_a_deploy_without_a_modal_workspace_id_stops(tmp_path, workspace_id) -> None: + result, runtime = _deploy(tmp_path, MODAL_WORKSPACE_ID_VALUE=workspace_id) + + assert result.returncode != 0 + assert "MODAL_WORKSPACE_ID_VALUE" in result.stderr + assert runtime == {} + + +def test_the_deploy_step_passes_the_modal_workspace_id() -> None: + deploy_entrypoint = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["deploy_entrypoint"] + environment = next( + step["env"] + for step in deploy_entrypoint["steps"] + if "deploy-cloud-run-simulation-entry.sh" in step.get("run", "") + ) + + assert environment["MODAL_WORKSPACE_ID_VALUE"] == "${{ vars.MODAL_WORKSPACE_ID }}" diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py index a6d870841..bc251481f 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py @@ -116,6 +116,7 @@ def cloud_run_deploy_environment(tmp_path: Path) -> dict[str, str]: "STAGE12_DATABASE_URL_SECRET_NAME": "stage12-staging-database-url", "MODAL_TOKEN_ID_SECRET_NAME": "modal-token-id", "MODAL_TOKEN_SECRET_SECRET_NAME": "modal-token-secret", + "MODAL_WORKSPACE_ID_VALUE": "ac-workspace0001", "SIMULATION_DATABASE_URL_SECRET_NAME": ( "simulation-staging-runtime-database-url" ), From 4a33fbcca9ab99ec18798e828c4286bed77aaa30 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:23:40 +0400 Subject: [PATCH 072/124] Deploy the safety check from the promoted revision, on a schedule deploy-cloud-run-simulation-safety-check.sh points the Cloud Run job -safety-check at one entrypoint revision: it copies the revision's image digest and service account, the secret versions it pins for SIMULATION_DATABASE_URL, MODAL_TOKEN_ID and MODAL_TOKEN_SECRET, and its plain telemetry settings (not the old-gateway secret), runs python -m policyengine_simulation_entry.safety_check as one task with no retries, and creates or updates a Cloud Scheduler entry that starts it every five minutes with the scheduler's own service account. A revision of another service is refused; a revision without the simulation database predates the safety check, so the schedule is paused. Only the promotion job (after the promoted revision passes every check, and back to the previous revision if this step fails) and the rollback workflow (after it restores traffic) call it, so a rejected candidate never records anything. The scheduler's service account comes from the new beta/prod variable SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT. The deployment notes list the one-time operator steps; staging has no safety check until it promotes its candidates (plan item 1.39). Co-Authored-By: Claude Opus 5.5 --- ...eploy-cloud-run-simulation-safety-check.sh | 108 +++++++ .../workflows/simulation-deploy.reusable.yml | 16 + .github/workflows/simulation-rollback.yml | 7 + .../DEPLOYMENT.md | 38 +++ .../tests/test_safety_check_deployment.py | 288 ++++++++++++++++++ 5 files changed, 457 insertions(+) create mode 100755 .github/scripts/deploy-cloud-run-simulation-safety-check.sh create mode 100644 projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py diff --git a/.github/scripts/deploy-cloud-run-simulation-safety-check.sh b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh new file mode 100755 index 000000000..c50e3d7d3 --- /dev/null +++ b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Point the safety-check Cloud Run job at the entrypoint revision that serves +# traffic, and keep its Cloud Scheduler entry. +# +# The job runs `python -m policyengine_simulation_entry.safety_check`: one +# recording pass per execution. It is updated only from a revision that was +# promoted (or restored by a rollback), so a rejected candidate never records +# anything. It copies the revision's image digest, service account, Modal +# token and database URL (at the secret versions the revision pins) and its +# plain telemetry settings; nothing else. A revision without the simulation +# database URL predates the safety check, so its schedule is paused. +# +# Environment: PROJECT_ID, REGION, SERVICE, SAFETY_CHECK_REVISION, +# SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT, RUNNER_TEMP. +set -euo pipefail + +fail() { + printf '%s\n' "$*" >&2 + exit 1 +} + +# Every five minutes: SAFETY_CHECK_INTERVAL in +# policyengine_simulation_entry.run_recording. +SCHEDULE="*/5 * * * *" +SECRETS=(SIMULATION_DATABASE_URL MODAL_TOKEN_ID MODAL_TOKEN_SECRET) +PLAIN_VARIABLES_FILTER='^(APP_ENVIRONMENT|CLOUD_RUN_REGION|OBSERVABILITY_[A-Z_]+|OTEL_[A-Z_]+|POLICYENGINE_OTEL_GOOGLE_AUDIENCE)$' + +for variable_name in PROJECT_ID REGION SERVICE SAFETY_CHECK_REVISION \ + SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT RUNNER_TEMP; do + [[ -n "${!variable_name:-}" ]] || fail "${variable_name} is required" +done +[[ "${PROJECT_ID}" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]] || fail "PROJECT_ID is invalid" +[[ "${REGION}" =~ ^[a-z]+-[a-z]+[0-9]+$ ]] || fail "REGION is invalid" +[[ "${SERVICE}" =~ ^[a-z]([a-z0-9-]{0,47}[a-z0-9])?$ ]] || fail "SERVICE is invalid" +[[ "${SAFETY_CHECK_REVISION}" =~ ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \ + || fail "SAFETY_CHECK_REVISION is invalid" +[[ "${SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \ + || fail "SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT is invalid" + +job="${SERVICE}-safety-check" +location=(--project "${PROJECT_ID}" --region "${REGION}") +scheduler_location=(--project "${PROJECT_ID}" --location "${REGION}") + +revision_json="$(gcloud run revisions describe "${SAFETY_CHECK_REVISION}" "${location[@]}" --format=json)" +revision_service="$(jq -r '.metadata.labels["serving.knative.dev/service"] // ""' <<<"${revision_json}")" +[[ "${revision_service}" == "${SERVICE}" ]] \ + || fail "${SAFETY_CHECK_REVISION} belongs to ${revision_service:-no service}, not ${SERVICE}" + +if ! jq -e '.spec.containers[0].env // [] | any(.name == "SIMULATION_DATABASE_URL")' >/dev/null <<<"${revision_json}"; then + if gcloud scheduler jobs describe "${job}" "${scheduler_location[@]}" >/dev/null 2>&1; then + gcloud scheduler jobs pause "${job}" "${scheduler_location[@]}" --quiet + fi + printf '%s has no simulation database, so the safety check is paused.\n' "${SAFETY_CHECK_REVISION}" + exit 0 +fi + +image="$(jq -er '.status.imageDigest // .spec.containers[0].image' <<<"${revision_json}")" +service_account="$(jq -er '.spec.serviceAccountName' <<<"${revision_json}")" + +job_secrets=() +for secret_variable in "${SECRETS[@]}"; do + reference="$(jq -er --arg name "${secret_variable}" ' + [.spec.containers[0].env[] | select(.name == $name) | .valueFrom.secretKeyRef + | select(.name != null and .key != null) | "\(.name):\(.key)"] + | if length == 1 then .[0] else error("not one secret reference") end + ' <<<"${revision_json}")" || fail "${SAFETY_CHECK_REVISION} has no pinned secret for ${secret_variable}" + job_secrets+=("${secret_variable}=${reference}") +done +job_secrets_csv="$(IFS=,; printf '%s' "${job_secrets[*]}")" + +environment_file="${RUNNER_TEMP}/simulation-safety-check-environment.yaml" +trap 'rm -f "${environment_file}"' EXIT +jq --arg filter "${PLAIN_VARIABLES_FILTER}" ' + [.spec.containers[0].env[] | select(.value != null and (.name | test($filter))) + | {(.name): .value}] | add // {} +' <<<"${revision_json}" > "${environment_file}" + +gcloud run jobs deploy "${job}" "${location[@]}" \ + --image "${image}" \ + --service-account "${service_account}" \ + --set-secrets "${job_secrets_csv}" \ + --env-vars-file "${environment_file}" \ + --command /opt/venv/bin/python \ + --args=-m,policyengine_simulation_entry.safety_check \ + --tasks 1 \ + --max-retries 0 \ + --task-timeout 600s \ + --cpu 1 \ + --memory 512Mi \ + --labels "serving-revision=${SAFETY_CHECK_REVISION}" \ + --quiet + +schedule_action=create +if gcloud scheduler jobs describe "${job}" "${scheduler_location[@]}" >/dev/null 2>&1; then + schedule_action=update +fi +gcloud scheduler jobs "${schedule_action}" http "${job}" "${scheduler_location[@]}" \ + --schedule "${SCHEDULE}" \ + --time-zone Etc/UTC \ + --uri "https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${job}:run" \ + --http-method POST \ + --oauth-service-account-email "${SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT}" \ + --attempt-deadline 60s \ + --quiet +if [[ "${schedule_action}" == "update" ]]; then + gcloud scheduler jobs resume "${job}" "${scheduler_location[@]}" --quiet +fi +printf 'The safety check runs %s from %s.\n' "${image}" "${SAFETY_CHECK_REVISION}" diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 8d74bf019..fbb3853ee 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -986,6 +986,15 @@ jobs: SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} run: uv run --frozen pytest authenticated_tests/ -v + # Only a verified, promoted revision records outcomes in the safety + # check, so a rejected candidate never records anything. + - name: Point the safety check at the promoted revision + id: safety_check + env: + SAFETY_CHECK_REVISION: ${{ env.TARGET_REVISION }} + SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT: ${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }} + run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh + - name: Restore previous revision after failed verification if: ${{ failure() && steps.promote.outcome == 'success' }} env: @@ -996,6 +1005,13 @@ jobs: SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION: ${{ env.TARGET_REVISION }} run: .github/scripts/set-cloud-run-simulation-entry-revision.sh + - name: Point the safety check back at the previous revision + if: ${{ failure() && steps.safety_check.outcome == 'failure' && env.PREVIOUS_REVISION != '' }} + env: + SAFETY_CHECK_REVISION: ${{ env.PREVIOUS_REVISION }} + SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT: ${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }} + run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh + finalize_routing: name: Finalize the Modal routing publication # Finish the durable journal after every gate, including failed uploads diff --git a/.github/workflows/simulation-rollback.yml b/.github/workflows/simulation-rollback.yml index d04d21040..83b2541e0 100644 --- a/.github/workflows/simulation-rollback.yml +++ b/.github/workflows/simulation-rollback.yml @@ -216,6 +216,13 @@ jobs: .github/scripts/set-cloud-run-simulation-entry-revision.sh fi + - name: Point the safety check at the restored revision + if: ${{ inputs.entrypoint_revision != '' }} + env: + SAFETY_CHECK_REVISION: ${{ inputs.entrypoint_revision }} + SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT: ${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }} + run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh + - name: Verify the stable service serves the restored release if: ${{ !inputs.resolve_pending_publication }} env: diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 140087530..e910794df 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -81,6 +81,44 @@ missing. The service already allows unauthenticated requests through Cloud Run (`--allow-unauthenticated`): callers prove who they are to the entrypoint, so Modal needs no Google credentials. +## Safety check + +A call whose report never arrives is recorded by the safety check, the Cloud +Run job `-safety-check`. Each execution runs one recording +pass (`python -m policyengine_simulation_entry.safety_check`) and exits: it +fails runs left `dispatching` for ten minutes and records what Modal reports +for calls dispatched, or last checked, at least five minutes ago. Cloud +Scheduler starts it every five minutes (`*/5 * * * *`, UTC): a lost report is +recorded within about ten minutes, and each execution is a single short +container start, so the job costs little when every call reports. + +`.github/scripts/deploy-cloud-run-simulation-safety-check.sh` points the job +at one entrypoint revision. It copies that revision's image digest, service +account, the secret versions it pins for `SIMULATION_DATABASE_URL`, +`MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, and its plain telemetry settings, +then creates or updates the schedule. Only two places call it, so the job +only ever runs a revision that serves traffic and a rejected candidate never +records anything: + +- the promotion job, after the promoted revision passes every check; if this + step fails, the previous revision is restored and the job is pointed back + at it; +- the rollback workflow, after it restores entrypoint traffic. A restored + revision without the simulation database predates the safety check, so the + schedule is paused. + +`beta` does not promote its candidate yet, so staging has no safety check +until it does (plan item 1.39). + +One-time operator steps, in each environment: enable the Cloud Scheduler +API; create the scheduler's service account, give it `roles/run.invoker` +on the job (or the project), and set its email in the GitHub environment +variable `SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT`; give the deploy +service account permission to deploy Cloud Run jobs and manage Cloud +Scheduler jobs, and `iam.serviceAccounts.actAs` on the scheduler's service +account. The job uses the entrypoint's runtime service account, which can +already read its secrets. + ## Candidate qualification For each eligible revision, the workflow first deploys `beta` diff --git a/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py b/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py new file mode 100644 index 000000000..f031bf68c --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py @@ -0,0 +1,288 @@ +"""Deploying the safety-check job from the revision that serves traffic. + +The job runs only the image of the promoted (or restored) entrypoint +revision, so a rejected candidate never records anything. The deploy copies +that revision's image, service account, database URL and Modal token, and +keeps a Cloud Scheduler entry that starts the job every five minutes. +""" + +from __future__ import annotations + +import json +from datetime import timedelta + +import pytest +from policyengine_simulation_entry.run_recording import SAFETY_CHECK_INTERVAL +from test_deployment_assets_support import ( + REUSABLE_DEPLOY_WORKFLOW, + ROLLBACK_WORKFLOW, + workflow_jobs, +) +from workflow_script_support import FakeCommands, run_script + +SCRIPT = "deploy-cloud-run-simulation-safety-check.sh" +SERVICE = "policyengine-simulation-entry" +REVISION = "policyengine-simulation-entry-p-123" +IMAGE = ( + "us-central1-docker.pkg.dev/policyengine/policyengine-simulation-entry/" + "policyengine-simulation-entry@sha256:" + "a" * 64 +) +RUNTIME_ACCOUNT = "sim-entry-prod-runtime@policyengine.iam.gserviceaccount.com" +SCHEDULER_ACCOUNT = "sim-safety-check-prod@policyengine.iam.gserviceaccount.com" +JOB = f"{SERVICE}-safety-check" + + +def _secret(name, secret, version): + return { + "name": name, + "valueFrom": {"secretKeyRef": {"name": secret, "key": version}}, + } + + +def _revision(*, service=SERVICE, environment=None): + return { + "apiVersion": "serving.knative.dev/v1", + "kind": "Revision", + "metadata": { + "name": REVISION, + "labels": {"serving.knative.dev/service": service}, + }, + "spec": { + "serviceAccountName": RUNTIME_ACCOUNT, + "containers": [ + { + "image": "us-central1-docker.pkg.dev/policyengine/x/y:sha", + "env": environment + if environment is not None + else [ + {"name": "APP_ENVIRONMENT", "value": "production"}, + {"name": "OLD_GATEWAY_URL", "value": "https://legacy.example"}, + {"name": "OBSERVABILITY_SERVICE_NAMESPACE", "value": "sim"}, + {"name": "CLOUD_RUN_REGION", "value": "us-central1"}, + _secret( + "SIMULATION_DATABASE_URL", "simulation-runtime-url", "4" + ), + _secret("MODAL_TOKEN_ID", "modal-token-id", "3"), + _secret("MODAL_TOKEN_SECRET", "modal-token-secret", "3"), + _secret( + "OLD_GATEWAY_AUTH_CLIENT_SECRET", "old-client-secret", "2" + ), + ], + } + ], + }, + "status": {"imageDigest": IMAGE}, + } + + +def _fakes(tmp_path, revision, *, schedule_exists): + fakes = FakeCommands.create(tmp_path) + revision_file = tmp_path / "revision.json" + revision_file.write_text(json.dumps(revision), encoding="utf-8") + environment_capture = tmp_path / "job-environment.json" + fakes.add( + "gcloud", + f""" + case "$1 $2 $3" in + "run revisions describe") cat "{revision_file}" ;; + "run jobs deploy") + while (($#)); do + if [[ "$1" == "--env-vars-file" ]]; then + cp "$2" "{environment_capture}" + fi + shift + done + ;; + "scheduler jobs describe") {"exit 0" if schedule_exists else "exit 1"} ;; + *) ;; + esac + """, + ) + return fakes, environment_capture + + +def _deploy(tmp_path, revision=None, *, schedule_exists=False, **overrides): + fakes, environment_capture = _fakes( + tmp_path, revision or _revision(), schedule_exists=schedule_exists + ) + settings = { + "PROJECT_ID": "policyengine", + "REGION": "us-central1", + "SERVICE": SERVICE, + "SAFETY_CHECK_REVISION": REVISION, + "SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT": SCHEDULER_ACCOUNT, + "RUNNER_TEMP": str(tmp_path), + } + result = run_script(SCRIPT, env=fakes.env(**(settings | overrides))) + return result, fakes, environment_capture + + +def _call(fakes, *prefix): + calls = [ + call + for call in fakes.calls("gcloud") + if call[1 : 1 + len(prefix)] == list(prefix) + ] + assert len(calls) == 1, calls + return calls[0] + + +def _flag(call, name): + return call[call.index(name) + 1] + + +def test_the_job_runs_the_serving_revisions_exact_image_and_identity(tmp_path) -> None: + result, fakes, _ = _deploy(tmp_path) + + assert result.returncode == 0, result.stderr + deploy = _call(fakes, "run", "jobs", "deploy") + assert deploy[4] == JOB + assert _flag(deploy, "--image") == IMAGE + assert _flag(deploy, "--service-account") == RUNTIME_ACCOUNT + assert _flag(deploy, "--command") == "/opt/venv/bin/python" + assert "--args=-m,policyengine_simulation_entry.safety_check" in deploy + assert _flag(deploy, "--tasks") == "1" + assert _flag(deploy, "--max-retries") == "0" + assert _flag(deploy, "--project") == "policyengine" + assert _flag(deploy, "--region") == "us-central1" + + +def test_the_job_gets_only_the_secrets_it_reads_at_the_revisions_versions( + tmp_path, +) -> None: + result, fakes, environment_capture = _deploy(tmp_path) + + assert result.returncode == 0, result.stderr + secrets = _flag(_call(fakes, "run", "jobs", "deploy"), "--set-secrets").split(",") + assert sorted(secrets) == [ + "MODAL_TOKEN_ID=modal-token-id:3", + "MODAL_TOKEN_SECRET=modal-token-secret:3", + "SIMULATION_DATABASE_URL=simulation-runtime-url:4", + ] + environment = json.loads(environment_capture.read_text(encoding="utf-8")) + assert environment == { + "APP_ENVIRONMENT": "production", + "OBSERVABILITY_SERVICE_NAMESPACE": "sim", + "CLOUD_RUN_REGION": "us-central1", + } + + +@pytest.mark.parametrize("schedule_exists", [False, True]) +def test_the_schedule_starts_the_job_every_interval(tmp_path, schedule_exists) -> None: + result, fakes, _ = _deploy(tmp_path, schedule_exists=schedule_exists) + + assert result.returncode == 0, result.stderr + action = "update" if schedule_exists else "create" + schedule = _call(fakes, "scheduler", "jobs", action, "http") + assert schedule[5] == JOB + assert _flag(schedule, "--location") == "us-central1" + assert _flag(schedule, "--schedule") == "*/5 * * * *" + assert _flag(schedule, "--uri") == ( + "https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/" + f"namespaces/policyengine/jobs/{JOB}:run" + ) + assert _flag(schedule, "--http-method") == "POST" + assert _flag(schedule, "--oauth-service-account-email") == SCHEDULER_ACCOUNT + + +def test_the_schedule_matches_the_safety_check_interval() -> None: + assert timedelta(minutes=5) == SAFETY_CHECK_INTERVAL + + +def test_a_revision_of_another_service_is_refused(tmp_path) -> None: + result, fakes, _ = _deploy(tmp_path, _revision(service="another-service")) + + assert result.returncode != 0 + assert "another-service" in result.stderr + assert [call[1:3] for call in fakes.calls("gcloud")] == [["run", "revisions"]] + + +def test_a_revision_without_the_simulation_database_pauses_the_schedule( + tmp_path, +) -> None: + """An older revision restored by a rollback has no safety check.""" + revision = _revision( + environment=[{"name": "APP_ENVIRONMENT", "value": "production"}] + ) + + result, fakes, _ = _deploy(tmp_path, revision, schedule_exists=True) + + assert result.returncode == 0, result.stderr + assert [call[1:4] for call in fakes.calls("gcloud")] == [ + ["run", "revisions", "describe"], + ["scheduler", "jobs", "describe"], + ["scheduler", "jobs", "pause"], + ] + + +@pytest.mark.parametrize( + ("variable", "value"), + [ + ("SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT", ""), + ("SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT", "not-an-account"), + ("SAFETY_CHECK_REVISION", ""), + ("SAFETY_CHECK_REVISION", "Bad Revision"), + ("SERVICE", ""), + ], +) +def test_incomplete_settings_stop_before_any_change(tmp_path, variable, value) -> None: + result, fakes, _ = _deploy(tmp_path, **{variable: value}) + + assert result.returncode != 0 + assert variable in result.stderr + assert fakes.calls("gcloud") == [] + + +def _steps(job): + return job["steps"] + + +def _step_named(steps, name): + return next(index for index, step in enumerate(steps) if step.get("name") == name) + + +def test_promotion_points_the_job_at_the_revision_after_it_is_verified() -> None: + steps = _steps(workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["promote_entrypoint"]) + promote = _step_named(steps, "Promote exact tested revision") + safety = _step_named(steps, "Point the safety check at the promoted revision") + restore = _step_named(steps, "Restore previous revision after failed verification") + restore_safety = _step_named( + steps, "Point the safety check back at the previous revision" + ) + + verification = [ + index + for index, step in enumerate(steps) + if index > promote and step.get("name", "").startswith("Verify") + ] + assert verification + assert max(verification) < safety < restore < restore_safety + assert SCRIPT in steps[safety]["run"] + assert steps[safety]["env"]["SAFETY_CHECK_REVISION"] == "${{ env.TARGET_REVISION }}" + assert steps[safety]["env"]["SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT"] == ( + "${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }}" + ) + assert "steps.safety_check.outcome == 'failure'" in steps[restore_safety]["if"] + assert steps[restore_safety]["env"]["SAFETY_CHECK_REVISION"] == ( + "${{ env.PREVIOUS_REVISION }}" + ) + + +def test_no_candidate_deploy_touches_the_job() -> None: + jobs = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW) + for name, job in jobs.items(): + runs = " ".join(step.get("run", "") for step in job.get("steps", [])) + if SCRIPT in runs: + assert name == "promote_entrypoint" + + +def test_a_rollback_points_the_job_at_the_restored_revision() -> None: + steps = _steps(workflow_jobs(ROLLBACK_WORKFLOW)["rollback"]) + restore = _step_named(steps, "Restore entrypoint traffic") + safety = _step_named(steps, "Point the safety check at the restored revision") + + assert restore < safety + assert steps[safety]["if"] == "${{ inputs.entrypoint_revision != '' }}" + assert steps[safety]["env"]["SAFETY_CHECK_REVISION"] == ( + "${{ inputs.entrypoint_revision }}" + ) From 8164f55d41371f54f7b5f07d7771b1f02f9abad0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:27:03 +0400 Subject: [PATCH 073/124] Add the sender a v2 Modal call uses to report that it ended src.modal.call_reports is v2 code only (the main path's run_simulation does not use it). Its interface for the v2 functions' last step (items 2.01 and 2.03): reporting_call_end wraps a top-level function's work, and when the block returns or raises an Exception it starts a separate call of the report_call_ended Modal function with the call ID and run IDs; the block's result or exception goes on unchanged, and a failure to start the report is logged, never raised. A signal sends nothing: Modal delivers pre-emption as KeyboardInterrupt, re-runs the call under its ID, and the re-run reports. The report goes through a separate call because sim asks Modal for the outcome without waiting, and a call's own output exists only once it has returned. send_call_ended_report, which that function runs, posts the CallEndedReport to the entrypoint's main URL with the container's Modal identity token, reports again after a wait (Retry-After, or a backoff from 1 to 30 seconds) while sim answers 429/502/503/504 or cannot be reached, stops on any other answer, and gives up after six attempts, leaving the call to the safety check. It never raises an Exception. Co-Authored-By: Claude Opus 5.5 --- .../src/modal/call_reports.py | 268 ++++++++++++++++++ .../tests/test_call_reports.py | 237 ++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 projects/policyengine-simulation-executor/src/modal/call_reports.py create mode 100644 projects/policyengine-simulation-executor/tests/test_call_reports.py diff --git a/projects/policyengine-simulation-executor/src/modal/call_reports.py b/projects/policyengine-simulation-executor/src/modal/call_reports.py new file mode 100644 index 000000000..81ef33db2 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/modal/call_reports.py @@ -0,0 +1,268 @@ +"""Report to the Simulation Entrypoint that a v2 Modal call ended. + +Interface for the v2 functions' last step (the economy coordinator, item +2.01, and the household functions, item 2.03). Wrap the work of a top-level +function whose call sim started:: + + with reporting_call_end( + modal_call_id=modal.current_function_call_id(), + simulation_run_ids=run_ids, # or report_run_id=report_run_id + start_report=report_call_ended.spawn, + ): + result = calculate(...) + return result + +When the block ends, normally or by raising an ``Exception``, the report is +started as a separate call of the ``report_call_ended`` Modal function and +the block's own result or exception goes on unchanged. Starting it never +raises. A signal sends nothing: Modal delivers pre-emption as +``KeyboardInterrupt``, re-runs the call under the same ID, and the re-run +reports. Segment calls report to their coordinator, not to sim. + +Why a separate call: this function's own output exists only once it has +returned, and sim asks Modal for the outcome itself, without waiting, so a +report sent from inside the call would always find it still running. +``report_call_ended`` runs ``send_call_ended_report_from_container``: it +posts the ``CallEndedReport`` (the call ID and run IDs, never a result) to +``SIMULATION_ENTRY_URL`` + ``CALL_ENDED_PATH`` with the container's Modal +identity token, and reports again after a wait while sim answers 503 or +cannot be reached, up to ``CALL_REPORT_ATTEMPTS`` times. A report that never +arrives is recorded by sim's safety check within about ten minutes. + +This module is v2 code only: the main path's ``run_simulation`` does not use +it. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.error +import urllib.request +from contextlib import contextmanager +from enum import StrEnum +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_contract.call_reports import ( + CALL_ENDED_PATH, + MODAL_IDENTITY_ENVIRONMENT_VARIABLE, + CallEndedReport, +) +from pydantic import ValidationError + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping, Sequence + from uuid import UUID + +logger = logging.getLogger(__name__) + +# The Simulation Entrypoint's main URL, which the deploy bakes into the +# report_call_ended function's environment. +ENTRY_URL_VARIABLE = "SIMULATION_ENTRY_URL" +CALL_REPORT_ATTEMPTS = 6 +FIRST_WAIT_SECONDS = 1.0 +LONGEST_WAIT_SECONDS = 30.0 +REQUEST_TIMEOUT_SECONDS = 15.0 +# sim answers these when it cannot record the outcome yet. +_RETRY_LATER_HTTP_STATUSES = frozenset({429, 502, 503, 504}) + + +class ReportDelivery(StrEnum): + """What sending one report came to.""" + + # sim recorded the outcome, or already had it. + DELIVERED = "delivered" + # sim refused the report; sending it again would not help. + REFUSED = "refused" + # sim could not record it after every attempt; the safety check will. + GAVE_UP = "gave_up" + # The container has no URL for sim or no identity token. + NOT_CONFIGURED = "not_configured" + + +class Poster(Protocol): + """Send one HTTP POST; return the status, headers and body.""" + + def __call__( + self, + url: str, + body: bytes, + headers: Mapping[str, str], + timeout: float, + ) -> tuple[int, Mapping[str, str], bytes]: + """Post ``body`` to ``url``; raise OSError when nothing came back.""" + ... + + +def post_with_urllib( + url: str, body: bytes, headers: Mapping[str, str], timeout: float +) -> tuple[int, Mapping[str, str], bytes]: + """Post with the standard library; an HTTP error status is an answer.""" + request = urllib.request.Request( # noqa: S310 # an https URL the deploy sets + url, data=body, headers=dict(headers), method="POST" + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 # as above + return response.status, dict(response.headers), response.read() + except urllib.error.HTTPError as error: + return error.code, dict(error.headers), error.read() + + +def _wait_seconds(attempt: int, headers: Mapping[str, str]) -> float: + retry_after = {name.lower(): value for name, value in headers.items()}.get( + "retry-after", "" + ) + backoff = min(FIRST_WAIT_SECONDS * 2**attempt, LONGEST_WAIT_SECONDS) + if retry_after.isdigit(): + return min(max(float(retry_after), backoff), LONGEST_WAIT_SECONDS) + return backoff + + +def _answer_status(body: bytes) -> str: + try: + answer = json.loads(body) + except ValueError: + return "" + return str(answer.get("status", "")) if isinstance(answer, dict) else "" + + +def send_call_ended_report( + report: CallEndedReport, + *, + entry_url: str, + identity_token: str, + post: Poster = post_with_urllib, + sleep: Callable[[float], None] = time.sleep, +) -> ReportDelivery: + """Post ``report`` to sim, reporting again while sim cannot record it yet. + + Never raises an ``Exception``: a report that cannot be delivered is + logged, and sim's safety check records the call instead. + """ + if not entry_url or not identity_token: + logger.warning( + "call_report_not_configured", + extra={"modal_call_id": report.modal_call_id}, + ) + return ReportDelivery.NOT_CONFIGURED + url = entry_url.rstrip("/") + CALL_ENDED_PATH + body = report.model_dump_json().encode() + headers = { + "Authorization": f"Bearer {identity_token}", + "Content-Type": "application/json", + } + answer_headers: Mapping[str, str] = {} + for attempt in range(CALL_REPORT_ATTEMPTS): + if attempt: + sleep(_wait_seconds(attempt - 1, answer_headers)) + try: + status, answer_headers, answer_body = post( + url, body, headers, REQUEST_TIMEOUT_SECONDS + ) + except OSError as error: # no answer: unreachable, reset or timed out + answer_headers = {} + logger.warning( + "call_report_not_answered", + extra={ + "modal_call_id": report.modal_call_id, + "attempt": attempt + 1, + "error_type": type(error).__name__, + }, + ) + continue + fields = { + "modal_call_id": report.modal_call_id, + "attempt": attempt + 1, + "http_status": status, + "call_report_status": _answer_status(answer_body), + } + if status < 300: # noqa: PLR2004 # any 2xx answer + logger.info("call_report_delivered", extra=fields) + return ReportDelivery.DELIVERED + if status not in _RETRY_LATER_HTTP_STATUSES: + logger.warning("call_report_refused", extra=fields) + return ReportDelivery.REFUSED + logger.info("call_report_not_recorded_yet", extra=fields) + logger.warning( + "call_report_given_up", + extra={"modal_call_id": report.modal_call_id, "attempts": CALL_REPORT_ATTEMPTS}, + ) + return ReportDelivery.GAVE_UP + + +def send_call_ended_report_from_container( + report: Mapping[str, object], + *, + post: Poster = post_with_urllib, + sleep: Callable[[float], None] = time.sleep, +) -> str: + """Run in ``report_call_ended``: send ``report`` with this container's settings. + + Return the ``ReportDelivery`` value. A malformed report is refused. + """ + try: + call_report = CallEndedReport.model_validate(report) + except ValidationError: + logger.warning("call_report_malformed") + return ReportDelivery.REFUSED.value + return send_call_ended_report( + call_report, + entry_url=os.environ.get(ENTRY_URL_VARIABLE, ""), + identity_token=os.environ.get(MODAL_IDENTITY_ENVIRONMENT_VARIABLE, ""), + post=post, + sleep=sleep, + ).value + + +def _start_report( + report: CallEndedReport, start_report: Callable[[dict[str, object]], object] +) -> None: + try: + start_report(report.model_dump(mode="json")) + except Exception as error: # a failed report must never fail the calculation + logger.exception( + "call_report_not_started", + extra={ + "modal_call_id": report.modal_call_id, + "error_type": type(error).__name__, + }, + ) + + +@contextmanager +def reporting_call_end( + *, + modal_call_id: str | None, + start_report: Callable[[dict[str, object]], object], + simulation_run_ids: Sequence[UUID] | None = None, + report_run_id: UUID | None = None, +) -> Iterator[None]: + """Report the call's end when the block returns or raises an Exception. + + ``start_report`` starts the ``report_call_ended`` function (its + ``spawn``). Without a call ID (outside Modal), or with run IDs the report + cannot carry, nothing is reported. + """ + report: CallEndedReport | None = None + try: + if modal_call_id is not None: + report = CallEndedReport( + modal_call_id=modal_call_id, + simulation_run_ids=( + None if simulation_run_ids is None else tuple(simulation_run_ids) + ), + report_run_id=report_run_id, + ) + except ValidationError: + logger.warning("call_report_malformed", extra={"modal_call_id": modal_call_id}) + if report is None: + yield + return + try: + yield + except Exception: + _start_report(report, start_report) + raise + _start_report(report, start_report) diff --git a/projects/policyengine-simulation-executor/tests/test_call_reports.py b/projects/policyengine-simulation-executor/tests/test_call_reports.py new file mode 100644 index 000000000..4d33d5219 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_call_reports.py @@ -0,0 +1,237 @@ +"""Reporting to the Simulation Entrypoint that a v2 Modal call ended.""" + +from __future__ import annotations + +import json +import urllib.error +from dataclasses import dataclass, field +from uuid import uuid4 + +import pytest +from policyengine_simulation_contract.call_reports import ( + CALL_ENDED_PATH, + CallEndedReport, +) +from src.modal.call_reports import ( + CALL_REPORT_ATTEMPTS, + ReportDelivery, + reporting_call_end, + send_call_ended_report, + send_call_ended_report_from_container, +) + +ENTRY_URL = "https://policyengine-simulation-entry-abc.a.run.app" +RUN_ID = uuid4() +REPORT = CallEndedReport(modal_call_id="fc-ended", simulation_run_ids=(RUN_ID,)) + + +@dataclass +class _Entrypoint: + """Answer each request with the next scripted (status, headers, body) or error.""" + + answers: list + requests: list = field(default_factory=list) + + def __call__(self, url, body, headers, timeout): + self.requests.append({"url": url, "body": body, "headers": headers}) + answer = self.answers.pop(0) + if isinstance(answer, BaseException): + raise answer + status, answer_headers, answer_body = answer + return status, answer_headers, json.dumps(answer_body).encode() + + +@dataclass +class _Waits: + seconds: list = field(default_factory=list) + + def __call__(self, seconds: float) -> None: + self.seconds.append(seconds) + + +def _send(answers, **settings): + entrypoint, waits = _Entrypoint(list(answers)), _Waits() + delivery = send_call_ended_report( + REPORT, + entry_url=settings.get("entry_url", ENTRY_URL), + identity_token=settings.get("identity_token", "modal-identity-token"), + post=entrypoint, + sleep=waits, + ) + return delivery, entrypoint, waits + + +def test_a_report_is_posted_with_the_containers_identity_token() -> None: + delivery, entrypoint, waits = _send([(200, {}, {"status": "recorded"})]) + + assert delivery is ReportDelivery.DELIVERED + (request,) = entrypoint.requests + assert request["url"] == ENTRY_URL + CALL_ENDED_PATH + assert request["headers"]["Authorization"] == "Bearer modal-identity-token" + assert request["headers"]["Content-Type"] == "application/json" + assert CallEndedReport.model_validate_json(request["body"]) == REPORT + assert waits.seconds == [] + + +def test_a_report_sim_cannot_record_yet_is_sent_again_after_its_wait() -> None: + delivery, entrypoint, waits = _send( + [ + (503, {"Retry-After": "3"}, {"status": "still_running"}), + (503, {}, {"status": "not_dispatched_yet"}), + (200, {}, {"status": "already_recorded"}), + ] + ) + + assert delivery is ReportDelivery.DELIVERED + assert len(entrypoint.requests) == 3 + assert waits.seconds[0] == 3 + assert waits.seconds[1] > 0 + + +@pytest.mark.parametrize( + "failure", + [urllib.error.URLError("unreachable"), TimeoutError(), ConnectionResetError()], + ids=["unreachable", "timeout", "reset"], +) +def test_a_report_that_does_not_arrive_is_sent_again(failure) -> None: + delivery, entrypoint, _ = _send([failure, (200, {}, {"status": "recorded"})]) + + assert delivery is ReportDelivery.DELIVERED + assert len(entrypoint.requests) == 2 + + +@pytest.mark.parametrize("status", [401, 404, 409, 422]) +def test_a_refused_report_is_not_sent_again(status) -> None: + delivery, entrypoint, waits = _send([(status, {}, {"status": "other_call"})]) + + assert delivery is ReportDelivery.REFUSED + assert len(entrypoint.requests) == 1 + assert waits.seconds == [] + + +def test_the_sender_gives_up_after_a_few_attempts_with_growing_waits() -> None: + answers = [(503, {}, {"status": "still_running"})] * CALL_REPORT_ATTEMPTS + + delivery, entrypoint, waits = _send(answers) + + assert delivery is ReportDelivery.GAVE_UP + assert len(entrypoint.requests) == CALL_REPORT_ATTEMPTS + assert len(waits.seconds) == CALL_REPORT_ATTEMPTS - 1 + assert waits.seconds == sorted(waits.seconds) + assert sum(waits.seconds) < 120 + + +def test_an_unexpected_answer_is_not_a_crash() -> None: + delivery, _, _ = _send([(200, {}, "not an answer")]) + + assert delivery is ReportDelivery.DELIVERED + + +def test_a_report_without_sims_url_or_a_token_is_not_sent() -> None: + delivery, entrypoint, _ = _send([], entry_url="") + assert delivery is ReportDelivery.NOT_CONFIGURED + delivery, entrypoint, _ = _send([], identity_token="") + assert delivery is ReportDelivery.NOT_CONFIGURED + assert entrypoint.requests == [] + + +def test_the_report_function_reads_its_settings_from_the_container( + monkeypatch, +) -> None: + monkeypatch.setenv("SIMULATION_ENTRY_URL", ENTRY_URL) + monkeypatch.setenv("MODAL_IDENTITY_TOKEN", "modal-identity-token") + entrypoint = _Entrypoint([(200, {}, {"status": "recorded"})]) + + delivery = send_call_ended_report_from_container( + REPORT.model_dump(mode="json"), post=entrypoint, sleep=_Waits() + ) + + assert delivery == "delivered" + assert entrypoint.requests[0]["url"] == ENTRY_URL + CALL_ENDED_PATH + + +@dataclass +class _ReportStarter: + """Stands in for ``report_call_ended.spawn``.""" + + failure: BaseException | None = None + started: list = field(default_factory=list) + + def __call__(self, report: dict) -> None: + if self.failure is not None: + raise self.failure + self.started.append(report) + + +def test_a_call_that_returns_reports_its_end() -> None: + starter = _ReportStarter() + + with reporting_call_end( + modal_call_id="fc-ended", simulation_run_ids=(RUN_ID,), start_report=starter + ): + pass + + assert [CallEndedReport.model_validate(report) for report in starter.started] == [ + REPORT + ] + + +def _failing_comparison(report_run_id, starter) -> None: + with reporting_call_end( + modal_call_id="fc-failed", report_run_id=report_run_id, start_report=starter + ): + msg = "calculation failed" + raise ValueError(msg) + + +def test_a_call_that_fails_reports_its_end_and_still_fails() -> None: + starter = _ReportStarter() + report_run_id = uuid4() + + with pytest.raises(ValueError, match="calculation failed"): + _failing_comparison(report_run_id, starter) + + assert starter.started == [ + { + "modal_call_id": "fc-failed", + "simulation_run_ids": None, + "report_run_id": str(report_run_id), + } + ] + + +@pytest.mark.parametrize("signal", [KeyboardInterrupt(), SystemExit(1)]) +def test_a_pre_empted_call_reports_nothing(signal) -> None: + """Modal re-runs a pre-empted call under its ID, and the re-run reports.""" + starter = _ReportStarter() + + with ( + pytest.raises(type(signal)), + reporting_call_end( + modal_call_id="fc-ended", simulation_run_ids=(RUN_ID,), start_report=starter + ), + ): + raise signal + + assert starter.started == [] + + +def test_a_report_that_cannot_start_never_fails_the_calculation() -> None: + starter = _ReportStarter(failure=RuntimeError("Modal unreachable")) + + with reporting_call_end( + modal_call_id="fc-ended", simulation_run_ids=(RUN_ID,), start_report=starter + ): + pass + + +def test_a_call_without_a_modal_call_id_reports_nothing() -> None: + """Run outside Modal (a local test), the call has no ID to report.""" + starter = _ReportStarter() + + with reporting_call_end( + modal_call_id=None, simulation_run_ids=(RUN_ID,), start_report=starter + ): + pass + + assert starter.started == [] From 46fd2cdae66c194859e277da95b024bb4a55fedd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:30:43 +0400 Subject: [PATCH 074/124] Declare report_call_ended in the v2 Modal app with sim's main URL The v2 app (src/modal/v2_app.py) gains report_call_ended, the function the v2 functions' last step starts and the only Modal function whose identity token sim accepts reports from; a check keeps its name equal to the contract's CALL_REPORT_FUNCTION_NAME. It runs send_call_ended_report_from_container on the coordinator's existing image with a quarter CPU, no retries, a five-minute timeout covering every attempt, and no secrets: its own identity token proves who it is. Its SIMULATION_ENTRY_URL is sim's main URL, set at deploy time: the v2 deploy job reads the entrypoint service's stable URL (status.url) with the new simulation-entry-main-url.sh, which refuses anything but an https URL, and passes it to modal deploy; v2_app refuses a URL that is not https. Nothing calls the function yet (items 2.01 and 2.03 do). Co-Authored-By: Claude Opus 5.5 --- .github/scripts/simulation-entry-main-url.sh | 24 +++++++ .../workflows/simulation-deploy.reusable.yml | 10 +++ .../DEPLOYMENT.md | 10 +++ .../tests/test_call_report_deployment.py | 63 +++++++++++++++++++ .../src/modal/v2_app.py | 47 ++++++++++++++ .../tests/test_call_report_function.py | 53 ++++++++++++++++ .../tests/test_stage12_modal_app.py | 1 + 7 files changed, 208 insertions(+) create mode 100755 .github/scripts/simulation-entry-main-url.sh create mode 100644 projects/policyengine-simulation-executor/tests/test_call_report_function.py diff --git a/.github/scripts/simulation-entry-main-url.sh b/.github/scripts/simulation-entry-main-url.sh new file mode 100755 index 000000000..8e86373d8 --- /dev/null +++ b/.github/scripts/simulation-entry-main-url.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Write the Simulation Entrypoint's main URL (its stable Cloud Run URL, which +# reaches the revision that serves traffic) to $GITHUB_OUTPUT as `url`, for +# the v2 Modal app's report_call_ended function. +# +# Environment: PROJECT_ID, REGION, SERVICE, GITHUB_OUTPUT. +set -euo pipefail + +fail() { + printf '%s\n' "$*" >&2 + exit 1 +} + +for variable_name in PROJECT_ID REGION SERVICE GITHUB_OUTPUT; do + [[ -n "${!variable_name:-}" ]] || fail "${variable_name} is required" +done + +url="$(gcloud run services describe "${SERVICE}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format='value(status.url)')" +[[ "${url}" =~ ^https://[a-z0-9.-]+$ ]] \ + || fail "${SERVICE} has no https main URL: ${url:-none}" +printf 'url=%s\n' "${url}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index fbb3853ee..0c1a8df4d 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -653,12 +653,22 @@ jobs: STAGE12_CACHE_BUCKET: ${{ vars.STAGE12_CACHE_BUCKET }} run: ../../.github/scripts/stage12-cache-precompute.sh "${{ inputs.modal_environment }}" + # The v2 functions report to sim's main URL that a call ended. + - name: Find the Simulation Entrypoint's main URL + id: entry_url + env: + PROJECT_ID: ${{ vars.SIMULATION_ENTRYPOINT_GCP_PROJECT_ID }} + REGION: ${{ vars.SIMULATION_ENTRYPOINT_GCP_REGION || 'us-central1' }} + SERVICE: ${{ inputs.entrypoint_service }} + run: .github/scripts/simulation-entry-main-url.sh + - name: Deploy the additional versioned v2 Modal application working-directory: projects/policyengine-simulation-executor env: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} MODAL_APP_NAME: ${{ steps.application.outputs.name }} + SIMULATION_ENTRY_URL: ${{ steps.entry_url.outputs.url }} STAGE12_EXPECT_POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} STAGE12_EXPECT_US_VERSION: ${{ needs.prepare.outputs.us_version }} STAGE12_EXPECT_UK_VERSION: ${{ needs.prepare.outputs.uk_version }} diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index e910794df..00b7a1ebf 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -81,6 +81,16 @@ missing. The service already allows unauthenticated requests through Cloud Run (`--allow-unauthenticated`): callers prove who they are to the entrypoint, so Modal needs no Google credentials. +The reports come from the v2 Modal app's `report_call_ended` function, which +the v2 functions start as their last step (`src/modal/call_reports.py` in the +executor). It posts to the entrypoint's main URL: the v2 deploy job reads the +service's stable URL (`status.url`) with +`.github/scripts/simulation-entry-main-url.sh` and passes it to `modal deploy` +as `SIMULATION_ENTRY_URL`. Only the revision that serves traffic receives +these requests. `beta` does not promote its candidate yet, so in staging the +main URL reaches an older revision until it does (plan item 1.39), and +staging calls are recorded by the safety check only once it exists there. + ## Safety check A call whose report never arrives is recorded by the safety check, the Cloud diff --git a/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py b/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py index 9b428dfbb..cb09bfad3 100644 --- a/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py @@ -19,6 +19,7 @@ REUSABLE_DEPLOY_WORKFLOW, workflow_jobs, ) +from workflow_script_support import FakeCommands, github_output, run_script def _deploy(tmp_path, **overrides): @@ -74,3 +75,65 @@ def test_the_deploy_step_passes_the_modal_workspace_id() -> None: ) assert environment["MODAL_WORKSPACE_ID_VALUE"] == "${{ vars.MODAL_WORKSPACE_ID }}" + + +URL_SCRIPT = "simulation-entry-main-url.sh" + + +def _find_url(tmp_path, service_url, **overrides): + fakes = FakeCommands.create(tmp_path) + fakes.add("gcloud", f"printf '%s\\n' '{service_url}'\n") + output = tmp_path / "github-output" + settings = { + "PROJECT_ID": "policyengine", + "REGION": "us-central1", + "SERVICE": "policyengine-simulation-entry", + "GITHUB_OUTPUT": str(output), + } + result = run_script(URL_SCRIPT, env=fakes.env(**(settings | overrides))) + return result, fakes, github_output(output) + + +def test_the_modal_functions_report_to_the_entrypoints_main_url(tmp_path) -> None: + url = "https://policyengine-simulation-entry-abc-uc.a.run.app" + + result, fakes, outputs = _find_url(tmp_path, url) + + assert result.returncode == 0, result.stderr + assert outputs == {"url": url} + (describe,) = fakes.calls("gcloud") + assert describe[1:5] == [ + "run", + "services", + "describe", + "policyengine-simulation-entry", + ] + assert "--format=value(status.url)" in describe + + +@pytest.mark.parametrize("service_url", ["", "http://insecure.example", "not a url"]) +def test_a_main_url_that_is_not_https_stops_the_deploy(tmp_path, service_url) -> None: + result, _, outputs = _find_url(tmp_path, service_url) + + assert result.returncode != 0 + assert outputs == {} + + +def test_the_v2_deploy_passes_the_main_url_to_the_modal_app() -> None: + job = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["deploy_stage12_v2"] + steps = job["steps"] + find = next( + index for index, step in enumerate(steps) if URL_SCRIPT in step.get("run", "") + ) + deploy = next( + index + for index, step in enumerate(steps) + if "src/modal/v2_app.py" in step.get("run", "") + ) + + assert find < deploy + assert steps[find]["id"] == "entry_url" + assert steps[find]["env"]["SERVICE"] == "${{ inputs.entrypoint_service }}" + assert steps[deploy]["env"]["SIMULATION_ENTRY_URL"] == ( + "${{ steps.entry_url.outputs.url }}" + ) diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 92642d673..3051e87ca 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from policyengine_simulation_contract.call_reports import CALL_REPORT_FUNCTION_NAME from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_executor import stage12_runtime from policyengine_simulation_executor.hf_access_validation import ( @@ -94,6 +95,23 @@ def _deploy_time_cache_inputs() -> tuple[str, dict | None]: STAGE12_CACHE_BUCKET, STAGE12_CACHE_MANIFEST = _deploy_time_cache_inputs() +def _deploy_time_entry_url() -> str: + """Return sim's main URL, which the deploy passes, for report_call_ended.""" + if not modal.is_local(): + return "" + url = os.environ.get("SIMULATION_ENTRY_URL", "") + if url and (url != url.strip() or not url.startswith("https://")): + msg = "SIMULATION_ENTRY_URL must be an https URL" + raise RuntimeError(msg) + return url + + +SIMULATION_ENTRY_URL = _deploy_time_entry_url() +# Every attempt to report, each with its request timeout, and every wait +# between them (src/modal/call_reports.py), with a margin. +CALL_REPORT_TIMEOUT_SECONDS = 300 + + def _external_assertions(environment: dict[str, str]) -> dict[str, str]: mapping = { "STAGE12_EXPECT_POLICYENGINE_VERSION": "policyengine_version", @@ -481,3 +499,32 @@ def coordinate_report( return result finally: runtime.shutdown() + + +@app.function( + image=coordinator_image, + cpu=0.25, + memory=512, + timeout=CALL_REPORT_TIMEOUT_SECONDS, + retries=0, + max_containers=20, + # No secrets: sim accepts this function's own Modal identity token. + env={"SIMULATION_ENTRY_URL": SIMULATION_ENTRY_URL}, +) +def report_call_ended(report: dict) -> str: + """Tell sim that the call in ``report`` ended; return how that went. + + The v2 functions start this as their last step + (``src.modal.call_reports.reporting_call_end``), so the reported call + has returned by the time sim asks Modal for its outcome. + """ + from src.modal.call_reports import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + send_call_ended_report_from_container, + ) + + return send_call_ended_report_from_container(report) + + +if report_call_ended.__name__ != CALL_REPORT_FUNCTION_NAME: + msg = "report_call_ended must keep the name sim accepts reports from" + raise RuntimeError(msg) diff --git a/projects/policyengine-simulation-executor/tests/test_call_report_function.py b/projects/policyengine-simulation-executor/tests/test_call_report_function.py new file mode 100644 index 000000000..cf9937db0 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_call_report_function.py @@ -0,0 +1,53 @@ +"""The v2 app's report_call_ended function, which tells sim that a call ended.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_contract.call_reports import CALL_REPORT_FUNCTION_NAME +from src.modal import call_reports +from test_stage12_modal_app import _load + +ENTRY_URL = "https://policyengine-simulation-entry-abc.a.run.app" + + +def test_the_v2_app_declares_the_function_sim_accepts_reports_from( + monkeypatch, +) -> None: + monkeypatch.setenv("SIMULATION_ENTRY_URL", ENTRY_URL) + module = _load(monkeypatch) + + declaration = dict(module.app.function_calls)[CALL_REPORT_FUNCTION_NAME] + + assert declaration["image"] is module.coordinator_image + assert declaration["env"] == {"SIMULATION_ENTRY_URL": ENTRY_URL} + assert declaration["retries"] == 0 + # Every attempt, each with its request timeout, and every wait between them. + assert declaration["timeout"] >= call_reports.CALL_REPORT_ATTEMPTS * ( + call_reports.REQUEST_TIMEOUT_SECONDS + call_reports.LONGEST_WAIT_SECONDS + ) + # It needs no secret: its own identity token proves who it is. + assert "secrets" not in declaration + + +def test_the_function_sends_the_report_with_its_containers_settings( + monkeypatch, +) -> None: + module = _load(monkeypatch) + sent = [] + monkeypatch.setattr( + call_reports, + "send_call_ended_report_from_container", + lambda report: sent.append(report) or "delivered", + ) + report = {"modal_call_id": "fc-ended", "report_run_id": "x"} + + assert module.report_call_ended(report) == "delivered" + assert sent == [report] + + +@pytest.mark.parametrize("url", ["http://entry.example", "entry.example", " https://x"]) +def test_a_deploy_with_a_url_that_is_not_https_stops(monkeypatch, url) -> None: + monkeypatch.setenv("SIMULATION_ENTRY_URL", url) + + with pytest.raises(RuntimeError, match="SIMULATION_ENTRY_URL"): + _load(monkeypatch) diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index ccf4a1918..9328cce7e 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -326,6 +326,7 @@ def test_v2_app_declares_validation_workers_and_non_http_coordinator( "run_single_simulation_us", "run_single_simulation_uk", "coordinate_report", + "report_call_ended", } assert functions["run_single_simulation_us"]["image"] is module.us_worker_image assert functions["run_single_simulation_uk"]["image"] is module.uk_worker_image From 68afe8c9341bb6b10f78096b2720019d6b0633b6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:34:03 +0400 Subject: [PATCH 075/124] Store dispatch errors that say only what is known From the third review of 1.06: two stored error messages could be false. A spawn that raised stored "Modal did not accept the call", but Modal may have created the call before the error; it now stores "Starting the Modal call raised ." And a dispatch failed as abandoned stored that its instance had stopped, which is also stored when the instance kept running but could not tell whether its record committed (RECORD_UNKNOWN); it now stores "No Modal call was recorded for the dispatch in time." Co-Authored-By: Claude Opus 5.5 --- .../run_transitions.py | 12 +++++------- .../tests/test_run_transitions.py | 5 +++++ .../policyengine_simulation_entry/run_dispatch.py | 2 +- .../tests/test_run_dispatch.py | 3 +++ 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 7686d639b..9d83327f2 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -60,7 +60,7 @@ class RunErrorCode(StrEnum): # Modal refused the call or never acknowledged it, or the acknowledged # call could not be recorded (the entrypoint then tries to cancel it). DISPATCH_FAILED = "dispatch_failed" - # The instance stopped between claiming the dispatch and recording the call. + # No Modal call was recorded in time after the dispatch was claimed. DISPATCH_ABANDONED = "dispatch_abandoned" # The Modal function raised. WORKER_ERROR = "worker_error" @@ -570,15 +570,13 @@ def fail_abandoned_dispatches( ) -> AbandonedDispatches: """Fail runs and comparison steps left in ``dispatching``. - A run stays ``dispatching`` only if the instance that claimed it - stopped before recording its Modal call. Failing it, with + A run stays ``dispatching`` when no Modal call was recorded for it: + the instance that claimed it stopped, or could not tell whether its + record committed and left the call running. Failing it, with ``dispatch_abandoned``, lets the same simulation be run again. """ now = self._clock() - message = ( - "The instance that claimed the dispatch stopped before it recorded " - "the Modal call." - ) + message = "No Modal call was recorded for the dispatch in time." with self.engine.begin() as connection: failed_runs = connection.execute( update(simulation_runs) diff --git a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py index eaec97a5d..e54fce729 100644 --- a/libs/policyengine-simulation-persistence/tests/test_run_transitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py @@ -507,6 +507,11 @@ def test_dispatches_left_for_ten_minutes_are_failed( failed_run = stores.runs.get_simulation_run(stalled.id) assert failed_run.status is RunStatus.FAILED assert failed_run.error_code == "dispatch_abandoned" + # The claimer may have stopped, or may not know whether its record + # committed; the message says only what is known. + assert failed_run.error_message == ( + "No Modal call was recorded for the dispatch in time." + ) assert ( stores.runs.get_report_run(report_run.id).comparison_error_code == "dispatch_abandoned" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index af392ffd8..07a691b9c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -218,7 +218,7 @@ async def _start( steps.fail, RunError( RunErrorCode.DISPATCH_FAILED, - f"Modal did not accept the call ({type(error).__name__}).", + f"Starting the Modal call raised {type(error).__name__}.", ), ) return DispatchOutcome.DISPATCH_FAILED diff --git a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py index 0309ace38..a90f420bc 100644 --- a/projects/policyengine-simulation-entry/tests/test_run_dispatch.py +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -103,6 +103,9 @@ async def test_a_refused_call_fails_the_runs_without_raising() -> None: assert run.error.code is RunErrorCode.DISPATCH_FAILED assert "NotFoundError" in run.error.message assert "no such function" not in run.error.message + # Modal may have created the call before the error, so the message says + # only that starting it raised. + assert run.error.message == "Starting the Modal call raised NotFoundError." @pytest.mark.asyncio From fe1f5e8c1ab93b49ac6af65ef64b17046d525cd9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:34:30 +0400 Subject: [PATCH 076/124] Describe the load check and the conditional writes for reported calls The load check's late writes stand for a call's report arriving while a safety-check pass checks the same call; its description says so, and that the same check of the reporting route against staging is an operator step. The persistence README says a reported call needs no claim, since its outcome is written conditionally too. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 7 +++--- .../tests/test_recording_load.py | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 85b878aae..faeff7e29 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -55,9 +55,10 @@ not delete them. `RunTransitions` makes every status change one conditional update on the expected status, so a second writer changes nothing: only the caller that moves runs from `pending` to `dispatching` starts their Modal call, the runs -one call computes change together, a recording pass claims a due call by -moving its next check time, results are written in the transaction that -marks a run succeeded, and dispatches left `dispatching` (and comparison +one call computes change together, a safety-check pass claims a due call by +moving its next check time (a call's own report needs no claim, since its +outcome is written conditionally too), results are written in the +transaction that marks a run succeeded, and dispatches left `dispatching` (and comparison steps whose simulation run failed) are failed so the work can be submitted again. A comparison step can start only once both of its simulation runs have succeeded; starting it is left to the stage 2 report path. diff --git a/libs/policyengine-simulation-persistence/tests/test_recording_load.py b/libs/policyengine-simulation-persistence/tests/test_recording_load.py index 00df05421..28b096c68 100644 --- a/libs/policyengine-simulation-persistence/tests/test_recording_load.py +++ b/libs/policyengine-simulation-persistence/tests/test_recording_load.py @@ -1,13 +1,16 @@ -"""A load check of the recording passes' conditional writes on PostgreSQL. - -Several simulated Simulation Entrypoint instances, each on its own engine, -repeatedly fail stalled dispatches, claim due calls and record an outcome for -each, as recording passes do, until every run has finished. A third of -the calls are household pairs (two runs under one call). Every call must be -recorded once, its results written once and all of its runs finished once, -and every stalled dispatch failed once, whatever the interleaving. The -elapsed time and the number of passes are printed, as a measurement; nothing -depends on them. +"""A load check of the conditional writes that record outcomes, on PostgreSQL. + +Several simulated writers, each on its own engine, repeatedly fail stalled +dispatches, claim due calls and record an outcome for each, as safety-check +passes do, until every run has finished. Some outcomes are written late, +after another writer could claim the same call and record it, as a report +from Modal can arrive while a pass checks the same call. A third of the calls +are household pairs (two runs under one call). Every call must be recorded +once, its results written once and all of its runs finished once, and every +stalled dispatch failed once, whatever the interleaving. The elapsed time and +the number of passes are printed, as a measurement; nothing depends on them. +The same check of the reporting route against the staging database is an +operator step. """ from __future__ import annotations From 42a0c938658ca05226fce23a1dad4122b50e1bdc Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:06:54 +0400 Subject: [PATCH 077/124] Answer a report with a wait whenever Modal's keys cannot be read From the extra review of the reworked 1.06: ModalIdentityVerifier mapped only PyJWKClientConnectionError to unavailable keys, but PyJWKClient wraps only URLError and TimeoutError. A key set that arrived truncated, reset or not as JSON escaped the route's handlers and became a 500, which the sender takes as a refusal, so the report was dropped and the outcome waited for the safety check. Any failure to fetch or read the keys, other than a token naming no published key, now raises ModalKeysUnavailableError, and the route answers any other error from the token check with 503 modal_unreachable as well. The check moved into its own helper, so the route keeps six returns. Co-Authored-By: Claude Opus 5.5 --- .../call_report_route.py | 36 +++++++++++++------ .../modal_identity.py | 6 ++++ .../tests/test_call_report_route.py | 17 +++++++++ .../tests/test_modal_identity.py | 36 +++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py index 1673b25cd..f21e56325 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py @@ -104,6 +104,28 @@ def _bearer_token(request: Request) -> str | None: return header[len(_BEARER_PREFIX) :].strip() or None +async def _caller( + request: Request, verifier: ModalCallerVerifier +) -> ModalIdentity | JSONResponse: + """Return the verified Modal caller, or the answer that refuses or defers it.""" + token = _bearer_token(request) + if token is None: + return _unauthorized() + try: + return await asyncio.to_thread(verifier.verify, token) + except ModalKeysUnavailableError: + logger.warning("call_report_modal_keys_unavailable") + return _answer(CallReportStatus.MODAL_UNREACHABLE) + except ModalIdentityError as error: + logger.warning("call_report_refused", extra={"reason": str(error)}) + return _unauthorized() + # Anything else is this instance's own problem, not a refusal: the sender + # reports again after the wait. + except Exception: + logger.exception("call_report_token_not_checked") + return _answer(CallReportStatus.MODAL_UNREACHABLE) + + def register_call_report_route( app: FastAPI, *, @@ -116,17 +138,9 @@ def register_call_report_route( async def report_call_ended(request: Request) -> JSONResponse: if verifier is None or recorder is None: return _answer(CallReportStatus.NOT_CONFIGURED) - token = _bearer_token(request) - if token is None: - return _unauthorized() - try: - identity = await asyncio.to_thread(verifier.verify, token) - except ModalKeysUnavailableError: - logger.warning("call_report_modal_keys_unavailable") - return _answer(CallReportStatus.MODAL_UNREACHABLE) - except ModalIdentityError as error: - logger.warning("call_report_refused", extra={"reason": str(error)}) - return _unauthorized() + identity = await _caller(request, verifier) + if isinstance(identity, JSONResponse): + return identity try: report = CallEndedReport.model_validate_json(await request.body()) except ValidationError as error: diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py index cbd971194..cecb7190c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py @@ -111,6 +111,12 @@ def verify(self, token: str) -> ModalIdentity: except jwt.PyJWTError as error: msg = f"the token names no key Modal publishes: {type(error).__name__}" raise ModalIdentityError(msg) from error + # PyJWKClient wraps only URLError and TimeoutError; a key set that + # arrives truncated, reset or not as JSON raises something else, and + # is no more the caller's fault. + except Exception as error: + msg = f"Modal's signing keys could not be read: {type(error).__name__}" + raise ModalKeysUnavailableError(msg) from error try: claims = jwt.decode( token, diff --git a/projects/policyengine-simulation-entry/tests/test_call_report_route.py b/projects/policyengine-simulation-entry/tests/test_call_report_route.py index 326d4dbf2..ebdeb996b 100644 --- a/projects/policyengine-simulation-entry/tests/test_call_report_route.py +++ b/projects/policyengine-simulation-entry/tests/test_call_report_route.py @@ -179,3 +179,20 @@ def test_the_route_is_not_in_the_public_openapi_document() -> None: document = _client(_Verifier(), _Recorder()).get("/openapi.json").json() assert CALL_ENDED_PATH not in document["paths"] + + +class _FailingVerifier: + def verify(self, token: str) -> ModalIdentity: + msg = "an error the verifier did not expect" + raise RuntimeError(msg) + + +def test_an_unexpected_verifier_failure_is_answered_with_a_wait() -> None: + """The sender reports again, instead of taking a 500 as a refusal.""" + recorder = _Recorder() + + response = _post(_client(_FailingVerifier(), recorder)) + + assert response.status_code == 503 + assert response.json() == {"status": "modal_unreachable"} + assert recorder.reports == [] diff --git a/projects/policyengine-simulation-entry/tests/test_modal_identity.py b/projects/policyengine-simulation-entry/tests/test_modal_identity.py index 5cd0eade2..7f4c542ae 100644 --- a/projects/policyengine-simulation-entry/tests/test_modal_identity.py +++ b/projects/policyengine-simulation-entry/tests/test_modal_identity.py @@ -147,3 +147,39 @@ def test_unreachable_modal_keys_are_not_a_refusal() -> None: with pytest.raises(ModalKeysUnavailableError): verifier.verify(_token()) + + +class _BrokenJwks: + """Modal's key set answered, but not with a key set PyJWT could read.""" + + def __init__(self, failure: Exception) -> None: + self.failure = failure + + def get_signing_key_from_jwt(self, _token: str): + raise self.failure + + +@pytest.mark.parametrize( + "failure", + [ + ValueError("Expecting value: line 1 column 1 (char 0)"), + ConnectionResetError("reset while reading the key set"), + OSError("truncated response"), + ], + ids=["not-json", "reset", "truncated"], +) +def test_any_failure_to_fetch_modal_keys_is_not_a_refusal(failure) -> None: + verifier = ModalIdentityVerifier(EXPECTED, signing_keys=_BrokenJwks(failure)) + + with pytest.raises(ModalKeysUnavailableError): + verifier.verify(_token()) + + +def test_a_token_naming_no_published_key_is_still_refused() -> None: + verifier = ModalIdentityVerifier( + EXPECTED, + signing_keys=_BrokenJwks(jwt.PyJWKClientError("Unable to find a signing key")), + ) + + with pytest.raises(ModalIdentityError): + verifier.verify(_token()) From 2ab3e3a4ed0bd6bbb038a5679c56830dc1fef30b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:09:41 +0400 Subject: [PATCH 078/124] Pause the safety check rather than run an image without it From the extra review of the reworked 1.06: - Pointing the job at a revision built from 1.04 or 1.05 deployed it: such a revision mounts the database URL and, with Stage 12, the Modal token, but its image has no safety_check module, so every execution would have failed. A revision now needs SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID, which every deploy has required since the safety check exists; otherwise the schedule is paused. - A failed step in the rollback workflow left the job on the image traffic had just moved away from. The script now takes `pause`, which only pauses the schedule; the rollback workflow runs it when pointing the job at the restored revision fails, and the promotion job when pointing it back at the previous revision fails. Co-Authored-By: Claude Opus 5.5 --- ...eploy-cloud-run-simulation-safety-check.sh | 54 +++++++++++---- .../workflows/simulation-deploy.reusable.yml | 5 ++ .github/workflows/simulation-rollback.yml | 6 ++ .../DEPLOYMENT.md | 12 ++-- .../tests/test_safety_check_deployment.py | 69 +++++++++++++++++++ 5 files changed, 127 insertions(+), 19 deletions(-) diff --git a/.github/scripts/deploy-cloud-run-simulation-safety-check.sh b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh index c50e3d7d3..54d830beb 100755 --- a/.github/scripts/deploy-cloud-run-simulation-safety-check.sh +++ b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh @@ -7,11 +7,15 @@ # promoted (or restored by a rollback), so a rejected candidate never records # anything. It copies the revision's image digest, service account, Modal # token and database URL (at the secret versions the revision pins) and its -# plain telemetry settings; nothing else. A revision without the simulation -# database URL predates the safety check, so its schedule is paused. +# plain telemetry settings; nothing else. A revision deployed before the +# safety check existed has no job code, so its schedule is paused instead. # -# Environment: PROJECT_ID, REGION, SERVICE, SAFETY_CHECK_REVISION, -# SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT, RUNNER_TEMP. +# With the argument `pause`, only pause the schedule: a workflow does this +# when pointing the job at a revision failed part way, so the job never keeps +# running the image traffic just moved away from. +# +# Environment: PROJECT_ID, REGION, SERVICE, and unless pausing, +# SAFETY_CHECK_REVISION, SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT, RUNNER_TEMP. set -euo pipefail fail() { @@ -19,38 +23,58 @@ fail() { exit 1 } +pause_schedule() { + if gcloud scheduler jobs describe "${job}" "${scheduler_location[@]}" >/dev/null 2>&1; then + gcloud scheduler jobs pause "${job}" "${scheduler_location[@]}" --quiet + fi +} + # Every five minutes: SAFETY_CHECK_INTERVAL in # policyengine_simulation_entry.run_recording. SCHEDULE="*/5 * * * *" SECRETS=(SIMULATION_DATABASE_URL MODAL_TOKEN_ID MODAL_TOKEN_SECRET) PLAIN_VARIABLES_FILTER='^(APP_ENVIRONMENT|CLOUD_RUN_REGION|OBSERVABILITY_[A-Z_]+|OTEL_[A-Z_]+|POLICYENGINE_OTEL_GOOGLE_AUDIENCE)$' -for variable_name in PROJECT_ID REGION SERVICE SAFETY_CHECK_REVISION \ - SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT RUNNER_TEMP; do +for variable_name in PROJECT_ID REGION SERVICE; do [[ -n "${!variable_name:-}" ]] || fail "${variable_name} is required" done [[ "${PROJECT_ID}" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]] || fail "PROJECT_ID is invalid" [[ "${REGION}" =~ ^[a-z]+-[a-z]+[0-9]+$ ]] || fail "REGION is invalid" [[ "${SERVICE}" =~ ^[a-z]([a-z0-9-]{0,47}[a-z0-9])?$ ]] || fail "SERVICE is invalid" -[[ "${SAFETY_CHECK_REVISION}" =~ ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \ - || fail "SAFETY_CHECK_REVISION is invalid" -[[ "${SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \ - || fail "SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT is invalid" job="${SERVICE}-safety-check" location=(--project "${PROJECT_ID}" --region "${REGION}") scheduler_location=(--project "${PROJECT_ID}" --location "${REGION}") +if [[ "${1:-}" == "pause" ]]; then + pause_schedule + printf 'The safety check schedule for %s is paused.\n' "${SERVICE}" + exit 0 +fi + +for variable_name in SAFETY_CHECK_REVISION SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT RUNNER_TEMP; do + [[ -n "${!variable_name:-}" ]] || fail "${variable_name} is required" +done +[[ "${SAFETY_CHECK_REVISION}" =~ ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \ + || fail "SAFETY_CHECK_REVISION is invalid" +[[ "${SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.gserviceaccount\.com$ ]] \ + || fail "SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT is invalid" + revision_json="$(gcloud run revisions describe "${SAFETY_CHECK_REVISION}" "${location[@]}" --format=json)" revision_service="$(jq -r '.metadata.labels["serving.knative.dev/service"] // ""' <<<"${revision_json}")" [[ "${revision_service}" == "${SERVICE}" ]] \ || fail "${SAFETY_CHECK_REVISION} belongs to ${revision_service:-no service}, not ${SERVICE}" -if ! jq -e '.spec.containers[0].env // [] | any(.name == "SIMULATION_DATABASE_URL")' >/dev/null <<<"${revision_json}"; then - if gcloud scheduler jobs describe "${job}" "${scheduler_location[@]}" >/dev/null 2>&1; then - gcloud scheduler jobs pause "${job}" "${scheduler_location[@]}" --quiet - fi - printf '%s has no simulation database, so the safety check is paused.\n' "${SAFETY_CHECK_REVISION}" +# Every revision deployed with the safety check has the simulation database +# and the expected Modal caller (SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID, +# which the deploy has required since then); an older one has no job code. +if ! jq -e ' + .spec.containers[0].env // [] + | any(.name == "SIMULATION_DATABASE_URL") + and any(.name == "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID") +' >/dev/null <<<"${revision_json}"; then + pause_schedule + printf '%s predates the safety check, so its schedule is paused.\n' "${SAFETY_CHECK_REVISION}" exit 0 fi diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 0c1a8df4d..58f071810 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -1016,12 +1016,17 @@ jobs: run: .github/scripts/set-cloud-run-simulation-entry-revision.sh - name: Point the safety check back at the previous revision + id: safety_check_restore if: ${{ failure() && steps.safety_check.outcome == 'failure' && env.PREVIOUS_REVISION != '' }} env: SAFETY_CHECK_REVISION: ${{ env.PREVIOUS_REVISION }} SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT: ${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }} run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh + - name: Pause the safety check after a failed restore + if: ${{ failure() && steps.safety_check_restore.outcome == 'failure' }} + run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh pause + finalize_routing: name: Finalize the Modal routing publication # Finish the durable journal after every gate, including failed uploads diff --git a/.github/workflows/simulation-rollback.yml b/.github/workflows/simulation-rollback.yml index 83b2541e0..90eeb8abb 100644 --- a/.github/workflows/simulation-rollback.yml +++ b/.github/workflows/simulation-rollback.yml @@ -217,12 +217,18 @@ jobs: fi - name: Point the safety check at the restored revision + id: safety_check if: ${{ inputs.entrypoint_revision != '' }} env: SAFETY_CHECK_REVISION: ${{ inputs.entrypoint_revision }} SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT: ${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }} run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh + # The job must not keep running the image traffic just moved away from. + - name: Pause the safety check after a failed update + if: ${{ failure() && steps.safety_check.outcome == 'failure' }} + run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh pause + - name: Verify the stable service serves the restored release if: ${{ !inputs.resolve_pending_publication }} env: diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 00b7a1ebf..4f445fd98 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -112,10 +112,14 @@ records anything: - the promotion job, after the promoted revision passes every check; if this step fails, the previous revision is restored and the job is pointed back - at it; -- the rollback workflow, after it restores entrypoint traffic. A restored - revision without the simulation database predates the safety check, so the - schedule is paused. + at it, and if that fails too, the schedule is paused; +- the rollback workflow, after it restores entrypoint traffic; if this step + fails, the schedule is paused, so the job never keeps running the image + traffic just moved away from. + +A revision deployed before the safety check existed (without +`SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID`) has no job code, so pointing the +job at it pauses the schedule instead. `beta` does not promote its candidate yet, so staging has no safety check until it does (plan item 1.39). diff --git a/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py b/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py index f031bf68c..01b009b51 100644 --- a/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py @@ -56,6 +56,10 @@ def _revision(*, service=SERVICE, environment=None): if environment is not None else [ {"name": "APP_ENVIRONMENT", "value": "production"}, + { + "name": "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID", + "value": "ac-workspace0001", + }, {"name": "OLD_GATEWAY_URL", "value": "https://legacy.example"}, {"name": "OBSERVABILITY_SERVICE_NAMESPACE", "value": "sim"}, {"name": "CLOUD_RUN_REGION", "value": "us-central1"}, @@ -286,3 +290,68 @@ def test_a_rollback_points_the_job_at_the_restored_revision() -> None: assert steps[safety]["env"]["SAFETY_CHECK_REVISION"] == ( "${{ inputs.entrypoint_revision }}" ) + + +def test_a_revision_from_before_the_safety_check_pauses_the_schedule(tmp_path) -> None: + """A 1.04 or 1.05 revision mounts the database and token but has no job code.""" + environment = [ + entry + for entry in _revision()["spec"]["containers"][0]["env"] + if entry["name"] != "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID" + ] + + result, fakes, _ = _deploy( + tmp_path, _revision(environment=environment), schedule_exists=True + ) + + assert result.returncode == 0, result.stderr + assert [call[1:4] for call in fakes.calls("gcloud")] == [ + ["run", "revisions", "describe"], + ["scheduler", "jobs", "describe"], + ["scheduler", "jobs", "pause"], + ] + + +@pytest.mark.parametrize("schedule_exists", [True, False]) +def test_pausing_stops_the_schedule_without_a_revision( + tmp_path, schedule_exists +) -> None: + fakes, _ = _fakes(tmp_path, _revision(), schedule_exists=schedule_exists) + settings = { + "PROJECT_ID": "policyengine", + "REGION": "us-central1", + "SERVICE": SERVICE, + } + + result = run_script(SCRIPT, "pause", env=fakes.env(**settings)) + + assert result.returncode == 0, result.stderr + expected = [["scheduler", "jobs", "describe"]] + if schedule_exists: + expected.append(["scheduler", "jobs", "pause"]) + assert [call[1:4] for call in fakes.calls("gcloud")] == expected + + +def test_a_failed_update_in_a_rollback_pauses_the_safety_check() -> None: + """The job must not keep running the image traffic just moved away from.""" + steps = _steps(workflow_jobs(ROLLBACK_WORKFLOW)["rollback"]) + safety = _step_named(steps, "Point the safety check at the restored revision") + pause = _step_named(steps, "Pause the safety check after a failed update") + + assert safety < pause + assert steps[safety]["id"] == "safety_check" + assert steps[pause]["if"] == ( + "${{ failure() && steps.safety_check.outcome == 'failure' }}" + ) + assert steps[pause]["run"] == f".github/scripts/{SCRIPT} pause" + + +def test_a_failed_restore_in_a_promotion_pauses_the_safety_check() -> None: + steps = _steps(workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["promote_entrypoint"]) + restore = _step_named(steps, "Point the safety check back at the previous revision") + pause = _step_named(steps, "Pause the safety check after a failed restore") + + assert restore < pause + assert steps[restore]["id"] == "safety_check_restore" + assert "steps.safety_check_restore.outcome == 'failure'" in steps[pause]["if"] + assert steps[pause]["run"] == f".github/scripts/{SCRIPT} pause" From dc5eb07543bafd0e002ec2675a83af48928c3ea1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:12:33 +0400 Subject: [PATCH 079/124] Log the safety check's passes through the observability runtime From the extra review of the reworked 1.06: the job configured logging with logging.basicConfig, whose plain format drops every field of a log record, so a pass's counts never reached the job's logs. The job now starts the process observability runtime, as the Modal workers do, logs JSON like the web service, and shuts the runtime down on exit. The pass summary carries its counts as policyengine_attributes, the only record fields the observability log handler keeps. Co-Authored-By: Claude Opus 5.5 --- .../safety_check.py | 42 ++++++++++++++++-- .../tests/test_safety_check.py | 44 ++++++++++++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py index 378b123ef..d630ee44e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py @@ -29,6 +29,10 @@ from dataclasses import asdict from typing import TYPE_CHECKING, Protocol +from policyengine_simulation_observability.observability import ( + init_process_observability, +) + from policyengine_simulation_entry.modal_calls import OWN_PROCESS_SIGNALS from policyengine_simulation_entry.simulation_database import SimulationDatabase @@ -44,6 +48,23 @@ EXIT_NOT_CONFIGURED = 2 +class Telemetry(Protocol): + """The observability runtime's part the job uses.""" + + def shutdown(self) -> None: + """Flush and stop telemetry.""" + ... + + +def _start_observability(environment: str) -> Telemetry: + return init_process_observability( + service_name="policyengine-simulation-safety-check", + service_role="simulation_safety_check", + platform="google_cloud_run", + environment=environment, + ) + + class DueOutcomeRecorder(Protocol): """Run one recording pass (``RunRecorder``).""" @@ -60,13 +81,25 @@ def main( *, environment: Mapping[str, str] | None = None, build_recorder: Callable[[str], DueOutcomeRecorder] = _database_recorder, + start_observability: Callable[[str], Telemetry] = _start_observability, ) -> int: """Run one pass and return the process exit status. - Only a signal to this process propagates; any other error fails the + Logs go through the observability runtime, as the web service's do. Only + a signal to this process propagates; any other error fails the execution, and the next scheduled execution tries again. """ values = os.environ if environment is None else environment + telemetry = start_observability(values.get("APP_ENVIRONMENT", "local")) + try: + return _run_pass(values, build_recorder) + finally: + telemetry.shutdown() + + +def _run_pass( + values: Mapping[str, str], build_recorder: Callable[[str], DueOutcomeRecorder] +) -> int: database_url = values.get("SIMULATION_DATABASE_URL", "") if not database_url: logger.error("safety_check_not_configured") @@ -81,10 +114,13 @@ def main( "safety_check_pass_failed", extra={"error_type": type(error).__name__} ) return EXIT_PASS_FAILED - logger.info("safety_check_pass", extra=asdict(recording)) + # The observability log handler keeps a record's policyengine_attributes. + logger.info( + "safety_check_pass", extra={"policyengine_attributes": asdict(recording)} + ) return EXIT_PASSED if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) + logging.getLogger().setLevel(logging.INFO) sys.exit(main()) diff --git a/projects/policyengine-simulation-entry/tests/test_safety_check.py b/projects/policyengine-simulation-entry/tests/test_safety_check.py index 20810acbd..18b49b0ef 100644 --- a/projects/policyengine-simulation-entry/tests/test_safety_check.py +++ b/projects/policyengine-simulation-entry/tests/test_safety_check.py @@ -32,14 +32,34 @@ async def record_due_outcomes(self) -> RecordingPass: return self.result -def _run(environment, recorder): +class _Telemetry: + """Stands in for the observability runtime the job starts and shuts down.""" + + def __init__(self) -> None: + self.environments = [] + self.shut_down = False + + def start(self, environment): + self.environments.append(environment) + return self + + def shutdown(self) -> None: + self.shut_down = True + + +def _run(environment, recorder, telemetry=None): built_for = [] def build(database_url): built_for.append(database_url) return recorder - status = safety_check.main(environment=environment, build_recorder=build) + telemetry = telemetry or _Telemetry() + status = safety_check.main( + environment=environment, + build_recorder=build, + start_observability=telemetry.start, + ) return status, built_for @@ -62,9 +82,23 @@ def test_the_pass_is_logged_with_what_it_found(caplog) -> None: (record,) = [ record for record in caplog.records if record.message == "safety_check_pass" ] - assert record.checked == 4 - assert record.succeeded == 1 - assert record.abandoned == 2 + # The observability log handler keeps only these attributes of a record. + assert record.policyengine_attributes["checked"] == 4 + assert record.policyengine_attributes["succeeded"] == 1 + assert record.policyengine_attributes["abandoned"] == 2 + + +def test_the_job_logs_through_the_observability_runtime_and_shuts_it_down() -> None: + telemetry = _Telemetry() + + _run( + {"SIMULATION_DATABASE_URL": DATABASE_URL, "APP_ENVIRONMENT": "production"}, + _OnePass(RecordingPass()), + telemetry, + ) + + assert telemetry.environments == ["production"] + assert telemetry.shut_down def test_an_execution_without_the_database_url_fails_without_a_pass() -> None: From 76bec96ca508a80c6225740771146c3e688bed9e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:12:53 +0400 Subject: [PATCH 080/124] Describe the dispatch, the report function and other_call as they work From the extra review of the reworked 1.06: run_dispatch still said a new call's first check is due shortly (it is the safety check's, one interval later) and that the recording loop collects a call whose record is unknown; report_call_ended's docstring said the reported call has always returned when sim asks Modal (usually it has; otherwise sim answers still_running and the function reports again); and other_call is also the answer for runs that finished without holding any call. Co-Authored-By: Claude Opus 5.5 --- .../call_reports.py | 3 ++- .../policyengine_simulation_entry/run_dispatch.py | 14 ++++++++------ .../src/modal/v2_app.py | 6 ++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py index 823457b70..4b9a32cf9 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py @@ -90,7 +90,8 @@ class CallReportStatus(StrEnum): NOT_CONFIGURED = "not_configured" # No run, or not every run, the report names exists. UNKNOWN_RUNS = "unknown_runs" - # The runs hold another Modal call. + # The runs hold another Modal call, or finished without holding one (for + # example failed as abandoned before the call's ID was stored). OTHER_CALL = "other_call" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index 07a691b9c..acdc426a6 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -5,11 +5,12 @@ 1. claim: move the runs from ``pending`` to ``dispatching``. Only the instance whose claim changes them goes on; every other caller stops here. 2. spawn the Modal call and wait only for Modal's acknowledgement. -3. record the call ID, moving the runs to ``running`` with their first check - due shortly; or, when Modal refused the call, fail them. +3. record the call ID, moving the runs to ``running``, with the safety + check's first look due one interval later (the call itself reports when + it ends); or, when starting the call raised, fail them. -If the instance stops between 1 and 3, the runs stay ``dispatching`` until a -recording pass fails them as abandoned. No route starts calls yet. +If the instance stops between 1 and 3, the runs stay ``dispatching`` until the +safety check fails them as abandoned. No route starts calls yet. """ from __future__ import annotations @@ -59,8 +60,9 @@ class DispatchOutcome(StrEnum): # cancelled. NOT_RECORDED = "not_recorded" # Recording the call raised and the database could not say whether the - # record committed; the call is left running. If it did, the recording - # loop collects the call; if not, the runs are failed as abandoned. + # record committed; the call is left running. If it did, the call's report + # or the safety check records its outcome; if not, the safety check fails + # the runs as abandoned. RECORD_UNKNOWN = "record_unknown" diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 3051e87ca..16d73d6b5 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -515,8 +515,10 @@ def report_call_ended(report: dict) -> str: """Tell sim that the call in ``report`` ended; return how that went. The v2 functions start this as their last step - (``src.modal.call_reports.reporting_call_end``), so the reported call - has returned by the time sim asks Modal for its outcome. + (``src.modal.call_reports.reporting_call_end``) and then return, so the + reported call has usually returned by the time sim asks Modal for its + outcome; when it has not, sim answers still_running and this reports + again after a wait. """ from src.modal.call_reports import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time send_call_ended_report_from_container, From 283ac6d15ed253573002b81903801536475d4bff Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:55:20 +0400 Subject: [PATCH 081/124] Let modal deploy import the v2 app: drop the check of report_call_ended's name v2_app checked report_call_ended.__name__ at import, after Modal's decorator had run. With the real modal package a declared function is a modal.Function, which has no __name__, so importing the module raised AttributeError and every `modal deploy` of the v2 app would have failed; the unit tests load the app with a fake Modal whose decorator returns the plain function, so they never saw it. The check is gone. The unit tests still require report_call_ended to be declared under the contract's CALL_REPORT_FUNCTION_NAME, and a new test imports the module with the real Modal package, as `modal deploy` does. Co-Authored-By: Claude Opus 5.5 --- .../src/modal/v2_app.py | 6 ---- .../tests/test_call_report_function.py | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 16d73d6b5..4dcc8b985 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -12,7 +12,6 @@ from pathlib import Path from typing import TYPE_CHECKING -from policyengine_simulation_contract.call_reports import CALL_REPORT_FUNCTION_NAME from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_executor import stage12_runtime from policyengine_simulation_executor.hf_access_validation import ( @@ -525,8 +524,3 @@ def report_call_ended(report: dict) -> str: ) return send_call_ended_report_from_container(report) - - -if report_call_ended.__name__ != CALL_REPORT_FUNCTION_NAME: - msg = "report_call_ended must keep the name sim accepts reports from" - raise RuntimeError(msg) diff --git a/projects/policyengine-simulation-executor/tests/test_call_report_function.py b/projects/policyengine-simulation-executor/tests/test_call_report_function.py index cf9937db0..9ce5cd33d 100644 --- a/projects/policyengine-simulation-executor/tests/test_call_report_function.py +++ b/projects/policyengine-simulation-executor/tests/test_call_report_function.py @@ -2,12 +2,18 @@ from __future__ import annotations +import os +import subprocess +import sys +from pathlib import Path + import pytest from policyengine_simulation_contract.call_reports import CALL_REPORT_FUNCTION_NAME from src.modal import call_reports from test_stage12_modal_app import _load ENTRY_URL = "https://policyengine-simulation-entry-abc.a.run.app" +PROJECT_ROOT = Path(__file__).resolve().parents[1] def test_the_v2_app_declares_the_function_sim_accepts_reports_from( @@ -51,3 +57,28 @@ def test_a_deploy_with_a_url_that_is_not_https_stops(monkeypatch, url) -> None: with pytest.raises(RuntimeError, match="SIMULATION_ENTRY_URL"): _load(monkeypatch) + + +def test_the_v2_app_imports_with_the_real_modal_package() -> None: + """`modal deploy` imports the module with Modal's own decorators. + + There a declared function is a ``modal.Function``, not the decorated + Python function, so nothing at import may rely on its Python attributes. + """ + environment = { + name: value + for name, value in os.environ.items() + if not name.startswith(("MODAL_", "STAGE12_", "SIMULATION_ENTRY_")) + } + + result = subprocess.run( + [sys.executable, "-c", "import src.modal.v2_app"], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=300, + ) + + assert result.returncode == 0, result.stderr[-2000:] From b53ea035c82906458296c0061d88ddb211f80f61 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:21:04 +0400 Subject: [PATCH 082/124] Store economy results as rows and rebuild them exactly Revision 0004 adds a table for each section of today's economy result model, SingleYearMacroOutput. A simulation's own figures belong to its simulation run (poverty_rates, inequality, cliff_impacts), so a reused current-law baseline keeps them once; the comparison belongs to the report run (budget_impacts, program_budget_impacts, decile_impacts and intra_decile_impacts for income and wealth, labor_supply_responses, labor_supply_decile_impacts, congressional_district_impacts, constituency_impacts, local_authority_impacts). Lists and maps keep their order in a position column. The report run gains result_sections (the optional sections its result holds, so null and an empty list stay distinct) and spm_provenance. write_economy_comparison writes a result's rows in the caller's transaction; with RunTransitions.complete_comparison that is the transaction marking the comparison step succeeded. check_storable first refuses a result the tables cannot hold exactly (decile maps naming different deciles, an empty share list, records of the wrong kind), and EconomyComparisonRecorder pairs it with validation against the result model for the recording loop. read_economy_comparison rebuilds the result from the rows plus labels the region catalog supplies: the model and data versions of the run's catalog version, and constituency and local-authority names and map positions by code. A round-trip test writes and reads back a fixed sample of eight results (US national, state and congressional district; UK national, nation, constituency and local authority; a budget-window year) and requires the same JSON, byte for byte. The sample is generated, sized like production results with values of varied sign and magnitude, because stored real results could not be read without starting new production calculations; an exported real result can replace any file. The persistence library now depends on the contract library for the result models. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 21 + .../pyproject.toml | 6 + .../__init__.py | 16 + .../economy_results.py | 745 ++ .../result_tables.py | 290 + .../runs.py | 9 + .../schema.py | 2 +- .../economy_results/uk_constituency_2026.json | 304 + .../uk_local_authority_2026.json | 314 + .../uk_nation_scotland_2026.json | 1107 ++ .../economy_results/uk_national_2026.json | 9466 +++++++++++++++++ .../us_budget_window_year_2030.json | 243 + .../us_congressional_district_ca_12_2026.json | 301 + .../economy_results/us_national_2026.json | 4295 ++++++++ .../economy_results/us_state_ca_2026.json | 760 ++ .../tests/result_support.py | 111 + .../tests/test_economy_results.py | 163 + .../tests/test_schema.py | 3 + .../uv.lock | 666 ++ .../versions/0004_economy_result_tables.py | 383 + .../policyengine-simulation-entry/uv.lock | 2 + 21 files changed, 19206 insertions(+), 1 deletion(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_constituency_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_budget_window_year_2030.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_congressional_district_ca_12_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_national_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_state_ca_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/result_support.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_economy_results.py create mode 100644 migrations/versions/0004_economy_result_tables.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index faeff7e29..69bf2f3a6 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -63,6 +63,27 @@ steps whose simulation run failed) are failed so the work can be submitted again. A comparison step can start only once both of its simulation runs have succeeded; starting it is left to the stage 2 report path. +### Results + +Every economy result is stored as rows (`result_tables`), one table per +section of today's result model, `SingleYearMacroOutput`: + +| Belongs to | Tables | +| --- | --- | +| A simulation run (its own figures, kept once when the run is reused) | `poverty_rates`, `inequality`, `cliff_impacts` | +| A report run (the comparison) | `budget_impacts`, `program_budget_impacts`, `decile_impacts` (income and wealth), `intra_decile_impacts` (income and wealth), `labor_supply_responses`, `labor_supply_decile_impacts`, `congressional_district_impacts`, `constituency_impacts`, `local_authority_impacts` | + +Lists and maps keep their order in a `position` column. The report run also +records which optional sections its result holds (`result_sections`) and its +SPM provenance. `write_economy_comparison` checks the result can be held +exactly (`check_storable`) and writes its rows in the transaction that marks +the comparison step succeeded (`RunTransitions.complete_comparison`); +`EconomyComparisonRecorder` pairs that writer with validation against the +result model. `read_economy_comparison` rebuilds the result exactly from the +rows and from labels the region catalog supplies (`EconomyResultLabels`): the +model and data versions of the run's catalog version, and constituency and +local-authority names and map positions by code. + ## Ownership rules These rules are registered in `rules/registry.toml` and checked by diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml index 28ab475b5..cf04f772f 100644 --- a/libs/policyengine-simulation-persistence/pyproject.toml +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -8,6 +8,8 @@ authors = [ license = {file = "../../LICENSE"} requires-python = ">=3.13,<3.14" dependencies = [ + # The result models the result tables store and rebuild. + "policyengine-simulation-contract", "psycopg[binary]>=3.2,<4", "sqlalchemy>=2,<3", ] @@ -28,6 +30,10 @@ build = [ "pyright>=1.1.401", ] +[tool.uv.sources] +policyengine-simulation-contract = { path = "../policyengine-simulation-contract", editable = true } +policyengine-simulation-observability = { path = "../policyengine-simulation-observability", editable = true } + [build-system] requires = ["hatchling>=1.26"] build-backend = "hatchling.build" diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index be715f3e5..81f846794 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -4,6 +4,15 @@ the migration environment compares with the database. """ +from policyengine_simulation_persistence.economy_results import ( + OPTIONAL_SECTIONS, + EconomyComparisonRecorder, + EconomyResultLabels, + GeographyLabel, + check_storable, + read_economy_comparison, + write_economy_comparison, +) from policyengine_simulation_persistence.engine import ( create_simulation_engine, normalized_database_url, @@ -52,11 +61,15 @@ __all__ = [ "ALEMBIC_VERSION_TABLE", "HEAD_REVISION", + "OPTIONAL_SECTIONS", "RUNTIME_ROLE", "SIMULATION_SCHEMA", "SUCCEEDED_RUN_REUSE_WINDOW", "AbandonedDispatches", "DueSimulationCall", + "EconomyComparisonRecorder", + "EconomyResultLabels", + "GeographyLabel", "NewReport", "NewReportRun", "NewSimulation", @@ -76,8 +89,11 @@ "SimulationRunRecord", "SimulationStore", "StartedSimulationRun", + "check_storable", "create_simulation_engine", "normalized_database_url", + "read_economy_comparison", "read_schema_revision", "simulation_metadata", + "write_economy_comparison", ] diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py new file mode 100644 index 000000000..be92198e4 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -0,0 +1,745 @@ +"""Write an economy comparison's result as rows, and rebuild it from them. + +``write_economy_comparison`` runs in the transaction that marks a report run's +comparison step succeeded (``RunTransitions.complete_comparison``), so a run +never succeeds without its rows. ``read_economy_comparison`` rebuilds today's +result format, ``SingleYearMacroOutput``, from the rows and from labels the +region catalog supplies: the model and data versions of the run's catalog +version, and the names and map positions of constituencies and local +authorities by code. + +A result the tables cannot hold exactly is refused before any write by +``check_storable``: the average and relative changes of each decile map must +name the same deciles in the same order, each decile's category share list +must hold at least one value, and the constituency and local-authority lists +must hold records of their own kind. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.macro_output import ( + ConstituencyImpactRecord, + DecileOutput, + IntraDecileOutput, + LaborSupplyResponseOutput, + LocalAuthorityImpactRecord, + SingleYearMacroOutput, +) +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence.result_tables import ( + budget_impacts, + cliff_impacts, + congressional_district_impacts, + constituency_impacts, + decile_impacts, + inequality, + intra_decile_impacts, + labor_supply_decile_impacts, + labor_supply_responses, + local_authority_impacts, + poverty_rates, + program_budget_impacts, +) +from policyengine_simulation_persistence.runs import ( + ReportRunRecord, + RunStatus, + report_runs, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from uuid import UUID + + from sqlalchemy import Connection, RowMapping, Table + +# The sections a result may leave null. Which of them a stored result holds is +# recorded on its report run, so a reader returns null exactly where the +# writer received null, including for empty lists. +OPTIONAL_SECTIONS = ( + "poverty_by_race", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact", + "congressional_district_impact", + "cliff_impact", +) +SIDES = ("baseline", "reform") + + +@dataclass(frozen=True) +class GeographyLabel: + """A constituency's or local authority's name and map position.""" + + name: str + x: int | None + y: int | None + + +@dataclass(frozen=True) +class EconomyResultLabels: + """What a stored result takes from the region catalog when it is read. + + The model and data versions belong to the run's catalog version; the + geography labels are looked up by code. + """ + + model_version: str + data_version: str + constituencies: Mapping[str, GeographyLabel] = field(default_factory=dict) + local_authorities: Mapping[str, GeographyLabel] = field(default_factory=dict) + + +# --- Checks before writing ------------------------------------------------ + + +def _check_decile_maps(name: str, decile: DecileOutput | None) -> None: + if decile is not None and list(decile.average) != list(decile.relative): + msg = f"{name}: average and relative must name the same deciles in order" + raise ValueError(msg) + + +def _check_intra_decile(name: str, intra: IntraDecileOutput | None) -> None: + if intra is not None and any(not shares for shares in intra.deciles.values()): + msg = f"{name}: every category needs at least one decile share" + raise ValueError(msg) + + +def _check_labor_supply(labor: LaborSupplyResponseOutput | None) -> None: + if labor is None: + return + metrics = labor.decile + orders = { + tuple(values) + for values in ( + metrics.average.income, + metrics.average.substitution, + metrics.relative.income, + metrics.relative.substitution, + ) + } + if len(orders) > 1: + msg = "labor_supply_response.decile: every map must name the same deciles" + raise ValueError(msg) + + +def check_storable(result: SingleYearMacroOutput) -> SingleYearMacroOutput: + """Return ``result`` if the tables can hold it exactly; else raise ValueError.""" + _check_decile_maps("decile", result.decile) + _check_decile_maps("wealth_decile", result.wealth_decile) + _check_intra_decile("intra_decile", result.intra_decile) + _check_intra_decile("intra_wealth_decile", result.intra_wealth_decile) + _check_labor_supply(result.labor_supply_response) + for section, record_type in ( + ("constituency_impact", ConstituencyImpactRecord), + ("local_authority_impact", LocalAuthorityImpactRecord), + ): + records = getattr(result, section) + if records is not None and not all( + isinstance(record, record_type) for record in records.root + ): + msg = f"{section} must hold only {record_type.__name__} records" + raise ValueError(msg) + return result + + +# --- Writing ---------------------------------------------------------------- + + +def _insert(connection: Connection, table: Table, rows: list[dict[str, Any]]) -> None: + if rows: + connection.execute(postgres_insert(table), rows) + + +def _insert_once( + connection: Connection, table: Table, rows: list[dict[str, Any]] +) -> None: + """Insert one simulation run's rows unless an earlier comparison wrote them. + + A reused simulation run, most often a current-law baseline, takes part in + many comparisons; its own figures are the same in each. + """ + if rows: + connection.execute(postgres_insert(table).on_conflict_do_nothing(), rows) + + +def _simulation_rows( + result: SingleYearMacroOutput, side: str, run_id: UUID +) -> dict[Table, list[dict[str, Any]]]: + poverty = [] + groups = [ + ("age", result.poverty.poverty, "poverty"), + ("age", result.poverty.deep_poverty, "deep_poverty"), + ("gender", result.poverty_by_gender.poverty, "poverty"), + ("gender", result.poverty_by_gender.deep_poverty, "deep_poverty"), + ] + if result.poverty_by_race is not None: + groups.append(("race", result.poverty_by_race.poverty, "poverty")) + for dimension, rates, measure in groups: + poverty.extend( + { + "simulation_run_id": run_id, + "measure": measure, + "group_dimension": dimension, + "group_value": group, + "rate": getattr(value, side), + } + for group, value in rates + ) + rows: dict[Table, list[dict[str, Any]]] = { + poverty_rates: poverty, + inequality: [ + { + "simulation_run_id": run_id, + **{name: getattr(value, side) for name, value in result.inequality}, + } + ], + } + if result.cliff_impact is not None: + cliff = getattr(result.cliff_impact, side) + rows[cliff_impacts] = [{"simulation_run_id": run_id, **cliff.model_dump()}] + return rows + + +def _decile_rows( + report_run_id: UUID, measure: str, decile: DecileOutput | None +) -> list[dict[str, Any]]: + if decile is None: + return [] + return [ + { + "report_run_id": report_run_id, + "measure": measure, + "decile": key, + "position": position, + "average_change": decile.average[key], + "relative_change": decile.relative[key], + } + for position, key in enumerate(decile.average) + ] + + +def _intra_decile_rows( + report_run_id: UUID, measure: str, intra: IntraDecileOutput | None +) -> list[dict[str, Any]]: + if intra is None: + return [] + rows = [ + { + "report_run_id": report_run_id, + "measure": measure, + "outcome": outcome, + "outcome_position": position, + "decile": index, + "share": share, + } + for position, (outcome, shares) in enumerate(intra.deciles.items()) + for index, share in enumerate(shares, start=1) + ] + rows.extend( + { + "report_run_id": report_run_id, + "measure": measure, + "outcome": outcome, + "outcome_position": position, + "decile": None, + "share": share, + } + for position, (outcome, share) in enumerate(intra.all.items()) + ) + return rows + + +def _labor_supply_rows( + report_run_id: UUID, labor: LaborSupplyResponseOutput | None +) -> dict[Table, list[dict[str, Any]]]: + if labor is None: + return {} + decile = labor.decile + return { + labor_supply_responses: [ + { + "report_run_id": report_run_id, + "substitution_lsr": labor.substitution_lsr, + "income_lsr": labor.income_lsr, + "relative_income_response": labor.relative_lsr.income, + "relative_substitution_response": labor.relative_lsr.substitution, + "total_change": labor.total_change, + "revenue_change": labor.revenue_change, + "baseline_hours": labor.hours.baseline, + "reform_hours": labor.hours.reform, + "hours_change": labor.hours.change, + "hours_income_effect": labor.hours.income_effect, + "hours_substitution_effect": labor.hours.substitution_effect, + } + ], + labor_supply_decile_impacts: [ + { + "report_run_id": report_run_id, + "decile": key, + "position": position, + "average_income_effect": decile.average.income[key], + "average_substitution_effect": decile.average.substitution[key], + "relative_income_effect": decile.relative.income[key], + "relative_substitution_effect": decile.relative.substitution[key], + } + for position, key in enumerate(decile.average.income) + ], + } + + +def _geography_rows( + report_run_id: UUID, result: SingleYearMacroOutput +) -> dict[Table, list[dict[str, Any]]]: + rows: dict[Table, list[dict[str, Any]]] = {} + if result.congressional_district_impact is not None: + rows[congressional_district_impacts] = [ + { + "report_run_id": report_run_id, + "position": position, + **record.model_dump(), + } + for position, record in enumerate( + result.congressional_district_impact.districts + ) + ] + for section, table, code_field in ( + ("constituency_impact", constituency_impacts, "constituency_code"), + ("local_authority_impact", local_authority_impacts, "local_authority_code"), + ): + records = getattr(result, section) + if records is None: + continue + rows[table] = [ + { + "report_run_id": report_run_id, + "position": position, + code_field: getattr(record, code_field), + "average_household_income_change": ( + record.average_household_income_change + ), + "relative_household_income_change": ( + record.relative_household_income_change + ), + "population": record.population, + } + for position, record in enumerate(records.root) + ] + return rows + + +def _comparison_rows( + report_run_id: UUID, result: SingleYearMacroOutput +) -> dict[Table, list[dict[str, Any]]]: + return { + budget_impacts: [ + {"report_run_id": report_run_id, **result.budget.model_dump()} + ], + program_budget_impacts: [ + { + "report_run_id": report_run_id, + "program": program, + "position": position, + **totals.model_dump(), + } + for position, (program, totals) in enumerate( + result.detailed_budget.root.items() + ) + ], + decile_impacts: [ + *_decile_rows(report_run_id, "income", result.decile), + *_decile_rows(report_run_id, "wealth", result.wealth_decile), + ], + intra_decile_impacts: [ + *_intra_decile_rows(report_run_id, "income", result.intra_decile), + *_intra_decile_rows(report_run_id, "wealth", result.intra_wealth_decile), + ], + **_labor_supply_rows(report_run_id, result.labor_supply_response), + **_geography_rows(report_run_id, result), + } + + +def write_economy_comparison( + connection: Connection, + report_run: ReportRunRecord, + result: SingleYearMacroOutput, +) -> None: + """Write an economy report run's result rows in the caller's transaction. + + The figures of each simulation go to its simulation run, the comparison to + the report run, and the SPM provenance and the optional sections the + result holds to the report run's own columns. + """ + check_storable(result) + if report_run.reform_simulation_run_id is None: + msg = "an economy comparison needs a reform simulation run" + raise ValueError(msg) + run_ids = ( + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + ) + for side, run_id in zip(SIDES, run_ids, strict=True): + for table, rows in _simulation_rows(result, side, run_id).items(): + _insert_once(connection, table, rows) + for table, rows in _comparison_rows(report_run.id, result).items(): + _insert(connection, table, rows) + connection.execute( + update(report_runs) + .where(report_runs.c.id == report_run.id) + .values( + result_sections=[ + section + for section in OPTIONAL_SECTIONS + if getattr(result, section) is not None + ], + spm_provenance=( + None + if result.spm_provenance is None + else result.spm_provenance.model_dump(mode="json") + ), + ) + ) + + +@dataclass(frozen=True) +class EconomyComparisonRecorder: + """Validate a comparison's output and write it, for the recording loop.""" + + def validate(self, output: object) -> SingleYearMacroOutput: + """Return the output as a result the tables can hold; else raise ValueError.""" + return check_storable(SingleYearMacroOutput.model_validate(output)) + + def write( + self, connection: Connection, report_run: ReportRunRecord, result: object + ) -> None: + """Write the validated result's rows in the caller's transaction.""" + if not isinstance(result, SingleYearMacroOutput): + msg = "the comparison recorder writes only validated economy results" + raise TypeError(msg) + write_economy_comparison(connection, report_run, result) + + +# --- Reading ---------------------------------------------------------------- + + +def _rows( + connection: Connection, + table: Table, + key_column: str, + key: UUID, + order: Sequence[str] = (), +) -> list[RowMapping]: + statement = select(table).where(table.c[key_column] == key) + if order: + statement = statement.order_by(*(table.c[name] for name in order)) + return list(connection.execute(statement).mappings()) + + +def _one( + connection: Connection, table: Table, key_column: str, key: UUID +) -> RowMapping: + rows = _rows(connection, table, key_column, key) + if len(rows) != 1: + msg = f"{table.name} has no row for {key}" + raise LookupError(msg) + return rows[0] + + +def _poverty(rows_by_side: dict[str, list[RowMapping]]) -> dict[str, Any]: + sections: dict[tuple[str, str], dict[str, dict[str, float]]] = {} + for side, side_rows in rows_by_side.items(): + for row in side_rows: + group = sections.setdefault((row["group_dimension"], row["measure"]), {}) + group.setdefault(row["group_value"], {})[side] = row["rate"] + return { + "poverty": { + "poverty": sections[("age", "poverty")], + "deep_poverty": sections[("age", "deep_poverty")], + }, + "poverty_by_gender": { + "poverty": sections[("gender", "poverty")], + "deep_poverty": sections[("gender", "deep_poverty")], + }, + "poverty_by_race": ( + {"poverty": sections[("race", "poverty")]} + if ("race", "poverty") in sections + else None + ), + } + + +def _simulation_sections( + connection: Connection, run_ids: tuple[UUID, UUID], sections: set[str] +) -> dict[str, Any]: + by_side = { + side: _rows(connection, poverty_rates, "simulation_run_id", run_id) + for side, run_id in zip(SIDES, run_ids, strict=True) + } + payload = _poverty(by_side) + if "poverty_by_race" not in sections: + payload["poverty_by_race"] = None + inequality_rows = [ + _one(connection, inequality, "simulation_run_id", run_id) for run_id in run_ids + ] + payload["inequality"] = { + name: { + side: row[name] for side, row in zip(SIDES, inequality_rows, strict=True) + } + for name in ("gini", "top_10_pct_share", "top_1_pct_share") + } + payload["cliff_impact"] = ( + { + side: {name: row[name] for name in ("cliff_gap", "cliff_share")} + for side, row in zip( + SIDES, + ( + _one(connection, cliff_impacts, "simulation_run_id", run_id) + for run_id in run_ids + ), + strict=True, + ) + } + if "cliff_impact" in sections + else None + ) + return payload + + +def _deciles( + rows: list[RowMapping], measure: str +) -> dict[str, dict[str, float]] | None: + measured = [row for row in rows if row["measure"] == measure] + if not measured: + return None + return { + "average": {row["decile"]: row["average_change"] for row in measured}, + "relative": {row["decile"]: row["relative_change"] for row in measured}, + } + + +def _intra_deciles(rows: list[RowMapping], measure: str) -> dict[str, Any]: + measured = [row for row in rows if row["measure"] == measure] + deciles: dict[str, list[float]] = {} + for row in sorted( + (row for row in measured if row["decile"] is not None), + key=lambda row: (row["outcome_position"], row["decile"]), + ): + deciles.setdefault(row["outcome"], []).append(row["share"]) + return { + "deciles": deciles, + "all": { + row["outcome"]: row["share"] + for row in sorted( + (row for row in measured if row["decile"] is None), + key=lambda row: row["outcome_position"], + ) + }, + } + + +def _labor_supply(connection: Connection, report_run_id: UUID) -> dict[str, Any]: + response = _one(connection, labor_supply_responses, "report_run_id", report_run_id) + deciles = _rows( + connection, + labor_supply_decile_impacts, + "report_run_id", + report_run_id, + order=("position",), + ) + return { + "substitution_lsr": response["substitution_lsr"], + "income_lsr": response["income_lsr"], + "relative_lsr": { + "income": response["relative_income_response"], + "substitution": response["relative_substitution_response"], + }, + "total_change": response["total_change"], + "revenue_change": response["revenue_change"], + "decile": { + statistic: { + effect: { + row["decile"]: row[f"{statistic}_{effect}_effect"] + for row in deciles + } + for effect in ("income", "substitution") + } + for statistic in ("average", "relative") + }, + "hours": { + "baseline": response["baseline_hours"], + "reform": response["reform_hours"], + "change": response["hours_change"], + "income_effect": response["hours_income_effect"], + "substitution_effect": response["hours_substitution_effect"], + }, + } + + +def _labelled_geographies( + rows: list[RowMapping], code_field: str, name_field: str, labels: Mapping +) -> list[dict[str, Any]]: + records = [] + for row in rows: + code = row[code_field] + label = labels.get(code) + if label is None: + msg = f"the region catalog has no label for {code}" + raise LookupError(msg) + records.append( + { + code_field: code, + name_field: label.name, + "x": label.x, + "y": label.y, + "average_household_income_change": row[ + "average_household_income_change" + ], + "relative_household_income_change": row[ + "relative_household_income_change" + ], + "population": row["population"], + } + ) + return records + + +def _geography_sections( + connection: Connection, + report_run_id: UUID, + sections: set[str], + labels: EconomyResultLabels, +) -> dict[str, Any]: + def ordered(table: Table) -> list[RowMapping]: + return _rows( + connection, table, "report_run_id", report_run_id, order=("position",) + ) + + payload: dict[str, Any] = { + "congressional_district_impact": None, + "constituency_impact": None, + "local_authority_impact": None, + } + if "congressional_district_impact" in sections: + payload["congressional_district_impact"] = { + "districts": [ + { + name: row[name] + for name in row.keys() # noqa: SIM118 # RowMapping keys + if name not in {"report_run_id", "position"} + } + for row in ordered(congressional_district_impacts) + ] + } + if "constituency_impact" in sections: + payload["constituency_impact"] = _labelled_geographies( + ordered(constituency_impacts), + "constituency_code", + "constituency_name", + labels.constituencies, + ) + if "local_authority_impact" in sections: + payload["local_authority_impact"] = _labelled_geographies( + ordered(local_authority_impacts), + "local_authority_code", + "local_authority_name", + labels.local_authorities, + ) + return payload + + +def _comparison_sections( + connection: Connection, + report_run_id: UUID, + sections: set[str], + labels: EconomyResultLabels, +) -> dict[str, Any]: + budget = _one(connection, budget_impacts, "report_run_id", report_run_id) + deciles = _rows( + connection, + decile_impacts, + "report_run_id", + report_run_id, + order=("measure", "position"), + ) + intra = _rows(connection, intra_decile_impacts, "report_run_id", report_run_id) + programs = _rows( + connection, + program_budget_impacts, + "report_run_id", + report_run_id, + order=("position",), + ) + return { + "budget": { + name: value for name, value in budget.items() if name != "report_run_id" + }, + "detailed_budget": { + row["program"]: { + "baseline": row["baseline"], + "reform": row["reform"], + "difference": row["difference"], + } + for row in programs + }, + "decile": _deciles(deciles, "income"), + "wealth_decile": ( + _deciles(deciles, "wealth") if "wealth_decile" in sections else None + ), + "intra_decile": _intra_deciles(intra, "income"), + "intra_wealth_decile": ( + _intra_deciles(intra, "wealth") + if "intra_wealth_decile" in sections + else None + ), + "labor_supply_response": ( + _labor_supply(connection, report_run_id) + if "labor_supply_response" in sections + else None + ), + **_geography_sections(connection, report_run_id, sections, labels), + } + + +def read_economy_comparison( + connection: Connection, report_run_id: UUID, labels: EconomyResultLabels +) -> SingleYearMacroOutput: + """Rebuild a succeeded economy report run's result from its rows. + + Raise LookupError when the report run has no succeeded comparison, or + when the labels lack a constituency or local authority the result names. + """ + report_run_row = ( + connection.execute(select(report_runs).where(report_runs.c.id == report_run_id)) + .mappings() + .first() + ) + if report_run_row is None: + msg = f"no report run {report_run_id}" + raise LookupError(msg) + report_run = ReportRunRecord.from_row(report_run_row) + if ( + report_run.comparison_status is not RunStatus.SUCCEEDED + or report_run.reform_simulation_run_id is None + ): + msg = f"report run {report_run_id} has no succeeded economy comparison" + raise LookupError(msg) + sections: set[str] = set(report_run_row["result_sections"] or ()) + run_ids = ( + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + ) + return SingleYearMacroOutput.model_validate( + { + "spm_provenance": report_run_row["spm_provenance"], + "model_version": labels.model_version, + "data_version": labels.data_version, + **_comparison_sections(connection, report_run_id, sections, labels), + **_simulation_sections(connection, run_ids, sections), + } + ) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py new file mode 100644 index 000000000..51385752e --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -0,0 +1,290 @@ +"""The result tables: every economy result stored as rows, attached to its run. + +Each section of today's economy result model (``SingleYearMacroOutput``) maps +to one table. Numbers that describe one simulation (a baseline's or a reform's +own poverty rates, inequality and cliff exposure) belong to that simulation +run, so a reused current-law baseline keeps them once; comparisons (the change +between baseline and reform) belong to the report run. Lists and maps keep +their order in a ``position`` column, so a reader rebuilds the result exactly. + +Constituency and local-authority names and map positions are not stored: they +come from the region catalog by code. The model and data versions are pinned +by the run's catalog version. The SPM provenance and the list of optional +sections a result holds are columns on the report run. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ( + CheckConstraint, + Column, + Double, + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + Table, + Text, + UniqueConstraint, + Uuid, +) + +from policyengine_simulation_persistence.runs import report_runs, simulation_runs +from policyengine_simulation_persistence.schema import simulation_metadata + +if TYPE_CHECKING: + from sqlalchemy.schema import SchemaItem + +POVERTY_MEASURES = ("poverty", "deep_poverty") +POVERTY_GROUPS = { + "age": ("child", "adult", "senior", "all"), + "gender": ("male", "female"), + "race": ("white", "black", "hispanic", "other"), +} +DECILE_MEASURES = ("income", "wealth") + + +def _values(values: tuple[str, ...]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def _simulation_run_key(table_name: str) -> list[SchemaItem]: + return [ + Column("simulation_run_id", Uuid(as_uuid=True), nullable=False), + ForeignKeyConstraint( + ["simulation_run_id"], + [simulation_runs.c.id], + name=f"fk_{table_name}_simulation_run", + ondelete="RESTRICT", + ), + ] + + +def _report_run_key(table_name: str) -> list[SchemaItem]: + return [ + Column("report_run_id", Uuid(as_uuid=True), nullable=False), + ForeignKeyConstraint( + ["report_run_id"], + [report_runs.c.id], + name=f"fk_{table_name}_report_run", + ondelete="RESTRICT", + ), + ] + + +def _numbers(*names: str) -> list[Column[float]]: + return [Column(name, Double, nullable=False) for name in names] + + +def _position() -> list[SchemaItem]: + return [ + Column("position", Integer, nullable=False), + CheckConstraint("position >= 0", name="position"), + ] + + +# --- Per simulation run ----------------------------------------------------- + +poverty_rates = Table( + "poverty_rates", + simulation_metadata, + *_simulation_run_key("poverty_rates"), + Column("measure", Text, nullable=False), + Column("group_dimension", Text, nullable=False), + Column("group_value", Text, nullable=False), + *_numbers("rate"), + PrimaryKeyConstraint( + "simulation_run_id", "measure", "group_dimension", "group_value" + ), + CheckConstraint(f"measure IN ({_values(POVERTY_MEASURES)})", name="measure"), + CheckConstraint( + " OR ".join( + f"(group_dimension = '{dimension}' " + f"AND group_value IN ({_values(group_values)}))" + for dimension, group_values in POVERTY_GROUPS.items() + ), + name="group", + ), +) + +inequality = Table( + "inequality", + simulation_metadata, + *_simulation_run_key("inequality"), + *_numbers("gini", "top_10_pct_share", "top_1_pct_share"), + PrimaryKeyConstraint("simulation_run_id"), +) + +cliff_impacts = Table( + "cliff_impacts", + simulation_metadata, + *_simulation_run_key("cliff_impacts"), + *_numbers("cliff_gap", "cliff_share"), + PrimaryKeyConstraint("simulation_run_id"), +) + +# --- Per report run --------------------------------------------------------- + +budget_impacts = Table( + "budget_impacts", + simulation_metadata, + *_report_run_key("budget_impacts"), + *_numbers( + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income", + ), + PrimaryKeyConstraint("report_run_id"), +) + +program_budget_impacts = Table( + "program_budget_impacts", + simulation_metadata, + *_report_run_key("program_budget_impacts"), + Column("program", Text, nullable=False), + *_position(), + *_numbers("baseline", "reform", "difference"), + PrimaryKeyConstraint("report_run_id", "program"), + UniqueConstraint("report_run_id", "position"), +) + +decile_impacts = Table( + "decile_impacts", + simulation_metadata, + *_report_run_key("decile_impacts"), + Column("measure", Text, nullable=False), + # The decile's key as the result names it ("1" to "10"). + Column("decile", Text, nullable=False), + *_position(), + *_numbers("average_change", "relative_change"), + PrimaryKeyConstraint("report_run_id", "measure", "decile"), + UniqueConstraint("report_run_id", "measure", "position"), + CheckConstraint(f"measure IN ({_values(DECILE_MEASURES)})", name="measure"), +) + +intra_decile_impacts = Table( + "intra_decile_impacts", + simulation_metadata, + *_report_run_key("intra_decile_impacts"), + Column("measure", Text, nullable=False), + # The size-of-change category, for example "Gain more than 5%". + Column("outcome", Text, nullable=False), + Column("outcome_position", Integer, nullable=False), + # 1 to 10 for a decile's share; null for the average across deciles. + Column("decile", SmallInteger, nullable=True), + *_numbers("share"), + UniqueConstraint( + "report_run_id", + "measure", + "outcome", + "decile", + postgresql_nulls_not_distinct=True, + ), + CheckConstraint(f"measure IN ({_values(DECILE_MEASURES)})", name="measure"), + CheckConstraint("decile IS NULL OR decile >= 1", name="decile"), + CheckConstraint("outcome_position >= 0", name="outcome_position"), +) + +labor_supply_responses = Table( + "labor_supply_responses", + simulation_metadata, + *_report_run_key("labor_supply_responses"), + *_numbers( + "substitution_lsr", + "income_lsr", + "relative_income_response", + "relative_substitution_response", + "total_change", + "revenue_change", + "baseline_hours", + "reform_hours", + "hours_change", + "hours_income_effect", + "hours_substitution_effect", + ), + PrimaryKeyConstraint("report_run_id"), +) + +labor_supply_decile_impacts = Table( + "labor_supply_decile_impacts", + simulation_metadata, + *_report_run_key("labor_supply_decile_impacts"), + Column("decile", Integer, nullable=False), + *_position(), + *_numbers( + "average_income_effect", + "average_substitution_effect", + "relative_income_effect", + "relative_substitution_effect", + ), + PrimaryKeyConstraint("report_run_id", "decile"), + UniqueConstraint("report_run_id", "position"), +) + +congressional_district_impacts = Table( + "congressional_district_impacts", + simulation_metadata, + *_report_run_key("congressional_district_impacts"), + Column("district", Text, nullable=False), + *_position(), + *_numbers( + "average_household_income_change", + "relative_household_income_change", + "winner_percentage", + "loser_percentage", + "no_change_percentage", + "population", + ), + PrimaryKeyConstraint("report_run_id", "district"), + UniqueConstraint("report_run_id", "position"), +) + +constituency_impacts = Table( + "constituency_impacts", + simulation_metadata, + *_report_run_key("constituency_impacts"), + Column("constituency_code", Text, nullable=False), + *_position(), + *_numbers( + "average_household_income_change", + "relative_household_income_change", + "population", + ), + PrimaryKeyConstraint("report_run_id", "constituency_code"), + UniqueConstraint("report_run_id", "position"), +) + +local_authority_impacts = Table( + "local_authority_impacts", + simulation_metadata, + *_report_run_key("local_authority_impacts"), + Column("local_authority_code", Text, nullable=False), + *_position(), + *_numbers( + "average_household_income_change", + "relative_household_income_change", + "population", + ), + PrimaryKeyConstraint("report_run_id", "local_authority_code"), + UniqueConstraint("report_run_id", "position"), +) + +ECONOMY_RESULT_TABLES = ( + poverty_rates, + inequality, + cliff_impacts, + budget_impacts, + program_budget_impacts, + decile_impacts, + intra_decile_impacts, + labor_supply_responses, + labor_supply_decile_impacts, + congressional_district_impacts, + constituency_impacts, + local_authority_impacts, +) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py index 3ad05ea99..59e6eb926 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -42,6 +42,7 @@ Uuid, text, ) +from sqlalchemy.dialects.postgresql import ARRAY, JSONB from policyengine_simulation_persistence import public_api from policyengine_simulation_persistence.reports import ( @@ -280,8 +281,16 @@ def _execution_columns(prefix: str, *, required: bool) -> list[Column[Any]]: *_execution_columns("comparison_", required=False), Column("created_at", DateTime(timezone=True), nullable=False), Column("updated_at", DateTime(timezone=True), nullable=False), + # Set with an economy comparison's result rows (item 1.07): the optional + # sections the result holds, and its SPM provenance, if any. + Column("result_sections", ARRAY(Text), nullable=True), + Column("spm_provenance", JSONB(none_as_null=True), nullable=True), CheckConstraint(f"year BETWEEN {FIRST_YEAR} AND {LAST_YEAR}", name="year"), CheckConstraint(f"trigger IN ({_values(RunTrigger)})", name="trigger"), + CheckConstraint( + "spm_provenance IS NULL OR jsonb_typeof(spm_provenance) = 'object'", + name="spm_provenance", + ), # An economy report run compares two simulation runs in its own comparison # step; a household report run has no comparison step. CheckConstraint( diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py index 7ef7081c6..00ec22e10 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -17,7 +17,7 @@ # The newest revision in migrations/versions. Each migration PR updates it, and # tests/test_schema.py fails when it differs from the chain's head. The # readiness check reports it next to the database's revision. -HEAD_REVISION = "0003" +HEAD_REVISION = "0004" NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_constituency_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_constituency_2026.json new file mode 100644 index 000000000..ea769f839 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_constituency_2026.json @@ -0,0 +1,304 @@ +{ + "spm_provenance": null, + "model_version": "2.62.1", + "data_version": "1.30.2", + "budget": { + "tax_revenue_impact": -69830165215.09961, + "state_tax_revenue_impact": 0.0, + "benefit_spending_impact": 30186894607.97075, + "budgetary_impact": -35233447033.36752, + "households": 121448725.73335086, + "baseline_net_income": 12071764008613.379 + }, + "detailed_budget": { + "income_tax": { + "baseline": -268622166.17482895, + "reform": -884002150.4505864, + "difference": 14871466.378840514 + }, + "national_insurance": { + "baseline": -925008683.1160302, + "reform": -132708632.67522825, + "difference": -860289152.8507621 + }, + "universal_credit": { + "baseline": -818573973.3122699, + "reform": -150961621.71497208, + "difference": 653704249.3440762 + } + }, + "decile": { + "average": { + "1": -2257.1882331021266, + "2": -1660.5662123579127, + "3": 764.5993344335358, + "4": 2686.253654742034, + "5": 462.617691704992, + "6": -619.917152095319, + "7": 2857.5306335575206, + "8": -2720.503916293462, + "9": 2150.810754292077, + "10": -1262.3442820099424 + }, + "relative": { + "1": 0.0, + "2": 0.5709136896467344, + "3": -0.0, + "4": 0.10305571244359135, + "5": 1e-12, + "6": 0.0, + "7": 0.5643682931333867, + "8": 0.20595871281932654, + "9": 1e-12, + "10": -3.5e-9 + } + }, + "inequality": { + "gini": { + "baseline": 0.36158235594456634, + "reform": 0.24842658485754932 + }, + "top_10_pct_share": { + "baseline": 0.1797667495831432, + "reform": 0.7798296305842437 + }, + "top_1_pct_share": { + "baseline": 0.08185501079576984, + "reform": 0.3002491185456253 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.49511635955525557, + "reform": 0.3434756899583733 + }, + "adult": { + "baseline": 0.44883419042779327, + "reform": 0.6089590190364036 + }, + "senior": { + "baseline": 0.07320086745966803, + "reform": 0.5119328306475491 + }, + "all": { + "baseline": 0.16496210364357322, + "reform": 0.34205580615985787 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.9332702121806375, + "reform": 0.4216983544767443 + }, + "adult": { + "baseline": 0.9620190834121097, + "reform": 0.07762048218079554 + }, + "senior": { + "baseline": 0.5580757526533747, + "reform": 0.7890941714903549 + }, + "all": { + "baseline": 0.8183533423673176, + "reform": 0.3401223621911955 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.3501783877191683, + "reform": 0.4966747952989876 + }, + "female": { + "baseline": 0.7968919758215943, + "reform": 0.06876294940686056 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.09359599608690361, + "reform": 0.2699392771281177 + }, + "female": { + "baseline": 0.6970420678269282, + "reform": 0.06499997571609484 + } + } + }, + "poverty_by_race": null, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.7311593346408904, + 0.30960737650937475, + 0.5779462307177181, + 0.6812371747339128, + 0.4456407672509217, + 0.7166277943983036, + 0.8870402922380918, + 0.34700525568845064, + 0.9406485666460938, + 0.355464109540346 + ], + "Gain less than 5%": [ + 0.6109195434830769, + 0.49369299455698146, + 0.21820777481967946, + 0.28743192649886173, + 0.7383633795947941, + 0.3978976785462327, + 0.9168162261800614, + 0.4965066990299619, + 0.16636628247192053, + 0.4016442563343041 + ], + "No change": [ + 0.27783913078445066, + 0.13692614301502581, + 0.4305216510890757, + 0.5502195528031965, + 0.7063967094965019, + 0.9864670810011861, + 0.6827230593874516, + 0.38044130025603773, + 0.23075150810868217, + 0.08298469466133207 + ], + "Lose less than 5%": [ + 0.15129838311640065, + 0.6585166769723302, + 0.012063059843798851, + 0.8310935615682863, + 0.1823428739811973, + 0.28193072232673766, + 0.14567639245798059, + 0.5345909623001036, + 0.6098124352569969, + 0.31861168111188654 + ], + "Lose more than 5%": [ + 0.125491512495977, + 0.8592019492051857, + 0.9502239496826584, + 0.6549664637163287, + 0.7397847477644152, + 0.45664372220287475, + 0.8709795011577717, + 0.9518862208315222, + 0.68057510106171, + 0.5592717408566095 + ] + }, + "all": { + "Gain more than 5%": 0.3980696305556508, + "Gain less than 5%": 0.39412001597536417, + "No change": 0.4815228181651947, + "Lose less than 5%": 0.4004426305163489, + "Lose more than 5%": 0.19060953756680787 + } + }, + "wealth_decile": { + "average": { + "1": 2908.005604539656, + "2": -356.23879005149695, + "3": -2340.430169997201, + "4": 604.3635630268873, + "5": -2385.7224136486675, + "6": 400.7016487985069, + "7": 219.71212781061357, + "8": 2693.692551416602, + "9": 682.4235778525865, + "10": -2578.106543079062 + }, + "relative": { + "1": -3.5e-9, + "2": 1e-12, + "3": 0.9554680239214713, + "4": 0.0, + "5": -3.5e-9, + "6": -3.5e-9, + "7": 1e-12, + "8": 0.0, + "9": 1e-12, + "10": -0.0 + } + }, + "intra_wealth_decile": { + "deciles": { + "Gain more than 5%": [ + 0.5163345189623215, + 0.2052150067015407, + 0.9520209471006497, + 0.36175245900901054, + 0.6900675858793588, + 0.9141457827913946, + 0.7581429595359372, + 0.29808969034627997, + 0.6429170806953686, + 0.09101055336145147 + ], + "Gain less than 5%": [ + 0.8454475943827271, + 0.5183968571327611, + 0.90825854366304, + 0.3556961698229455, + 0.22279275605523874, + 0.5415671227801955, + 0.5026970232253148, + 0.6364419253397112, + 0.613228222813541, + 0.7883992641041133 + ], + "No change": [ + 0.758322424088633, + 0.19514603023289578, + 0.2393876747662793, + 0.4006843696525172, + 0.8033260645474455, + 0.19991798339514966, + 0.49278184291394456, + 0.7310039924754212, + 0.98960358670307, + 0.7901141366319249 + ], + "Lose less than 5%": [ + 0.4722400624988553, + 0.19364494601280935, + 0.6051390316822758, + 0.344280924254862, + 0.8085657427983075, + 0.723127961069629, + 0.34951966222376096, + 0.974514978860586, + 0.08053812548862638, + 0.10215714742873472 + ], + "Lose more than 5%": [ + 0.4700799822561902, + 0.3377374798385304, + 0.48265330213357793, + 0.9852489970647419, + 0.6102621468934083, + 0.0019083133300648036, + 0.9091991979850682, + 0.34400690197679207, + 0.6431330970285719, + 0.834648807798219 + ] + }, + "all": { + "Gain more than 5%": 0.11990363083613764, + "Gain less than 5%": 0.3885357438199436, + "No change": 0.7114929836253856, + "Lose less than 5%": 0.1993194034549053, + "Lose more than 5%": 0.8890110044071206 + } + }, + "labor_supply_response": null, + "constituency_impact": [], + "local_authority_impact": null, + "congressional_district_impact": null, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json new file mode 100644 index 000000000..3d309bacd --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json @@ -0,0 +1,314 @@ +{ + "spm_provenance": null, + "model_version": "2.62.1", + "data_version": "1.30.2", + "budget": { + "tax_revenue_impact": 92459007166.87656, + "state_tax_revenue_impact": 0.0, + "benefit_spending_impact": -74733820269.8281, + "budgetary_impact": -54658828123.79024, + "households": 134096338.45743215, + "baseline_net_income": 11170370536101.506 + }, + "detailed_budget": { + "income_tax": { + "baseline": -505118030.14182985, + "reform": 998257078.3251581, + "difference": -581204736.2221744 + }, + "national_insurance": { + "baseline": 283736870.144214, + "reform": -81732474.45520659, + "difference": -93735137.72371434 + }, + "universal_credit": { + "baseline": -10034612.22164459, + "reform": -615538315.5146357, + "difference": 661042547.2659434 + } + }, + "decile": { + "average": { + "1": -2462.6062593564625, + "2": -1594.9022430617679, + "3": -2880.0521626549153, + "4": -1399.3952339808131, + "5": -554.0168222342572, + "6": 2412.38580246977, + "7": -725.5474749504656, + "8": -2317.6144363152007, + "9": -1449.8597638085234, + "10": 2949.6143824264072 + }, + "relative": { + "1": 0.06308868070221851, + "2": 0.0, + "3": 1e-12, + "4": 0.0, + "5": 0.49758053499007204, + "6": 0.9013749600046599, + "7": 0.808392704592183, + "8": 0.0, + "9": -0.0, + "10": 0.14820779074761503 + } + }, + "inequality": { + "gini": { + "baseline": 0.4562631750773002, + "reform": 0.5843663579297041 + }, + "top_10_pct_share": { + "baseline": 0.4451369217459352, + "reform": 0.5573200610033924 + }, + "top_1_pct_share": { + "baseline": 0.8550395732974745, + "reform": 0.4261199543995837 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.1582997572742052, + "reform": 0.6013118855477607 + }, + "adult": { + "baseline": 0.5318079940243159, + "reform": 0.31378663109351257 + }, + "senior": { + "baseline": 0.35703051052987367, + "reform": 0.643906472947396 + }, + "all": { + "baseline": 0.4946319125862524, + "reform": 0.6239857166830313 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.3087913032633056, + "reform": 0.35573908335507054 + }, + "adult": { + "baseline": 0.28435448936962193, + "reform": 0.6570561831550361 + }, + "senior": { + "baseline": 0.8081220455707656, + "reform": 0.8567220084921808 + }, + "all": { + "baseline": 0.9130649320877698, + "reform": 0.5449358911808955 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.34420243087326197, + "reform": 0.9079041928940726 + }, + "female": { + "baseline": 0.29570667701386866, + "reform": 0.21979131003351726 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.03365358280689901, + "reform": 0.2547728298375461 + }, + "female": { + "baseline": 0.29835312690076377, + "reform": 0.7053958616515078 + } + } + }, + "poverty_by_race": null, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.4169195903483851, + 0.6574407249410744, + 0.11626222108439832, + 0.9325438414750353, + 0.8521088806900794, + 0.02563335151827517, + 0.9730740172153319, + 0.16067546829614598, + 0.11648309240652333, + 0.7964178976736825 + ], + "Gain less than 5%": [ + 0.38045272485774695, + 0.8363966880005376, + 0.5691257747680492, + 0.9051326083519999, + 0.13903840191737538, + 0.5639972943518561, + 0.4071549580839391, + 0.08013950726768904, + 0.06997546768743013, + 0.7168440229826548 + ], + "No change": [ + 0.03403311286312238, + 0.6632128365823879, + 0.7642800250719824, + 0.5272840362799457, + 0.1536563844062374, + 0.9364619441697246, + 0.4882392787052493, + 0.1485330386465159, + 0.6044432525558315, + 0.18967984629379342 + ], + "Lose less than 5%": [ + 0.9126148979394935, + 0.34079267372439015, + 0.6582072941487361, + 0.12074892031961626, + 0.29941097908798053, + 0.3254344684255279, + 0.3660441115717603, + 0.6458464848673228, + 0.4990195871150638, + 0.060665971600854185 + ], + "Lose more than 5%": [ + 0.6172602477157224, + 0.8829050359514565, + 0.8689035084797285, + 0.8085087313537289, + 0.8164383663237682, + 0.1719727529421302, + 0.3375224245329854, + 0.4310317668432285, + 0.1209512582671255, + 0.200459469895078 + ] + }, + "all": { + "Gain more than 5%": 0.22398320210880074, + "Gain less than 5%": 0.8113783037154952, + "No change": 0.7902104717084726, + "Lose less than 5%": 0.381087766006753, + "Lose more than 5%": 0.10845992401790983 + } + }, + "wealth_decile": { + "average": { + "1": 1325.7679247568183, + "2": 887.4898162388139, + "3": 1316.0780864723158, + "4": 86.8957367028691, + "5": -2457.4312396873806, + "6": 2571.1630793496124, + "7": 383.1595656463318, + "8": 1898.7683219078772, + "9": -267.98244302632025, + "10": -2694.442740194193 + }, + "relative": { + "1": 0.0, + "2": 0.0, + "3": 0.7083211213398463, + "4": 1e-12, + "5": 0.8292649495036908, + "6": 0.09333568050454522, + "7": 0.5025826008563596, + "8": -0.0, + "9": -3.5e-9, + "10": -0.0 + } + }, + "intra_wealth_decile": { + "deciles": { + "Gain more than 5%": [ + 0.006685229313061147, + 0.6131688080465992, + 0.9088512441506565, + 0.7432899871545285, + 0.9275799908266388, + 0.9817309707617923, + 0.22366815531507644, + 0.2964193603368207, + 0.547338593151295, + 0.9768034722225887 + ], + "Gain less than 5%": [ + 0.3692537531761815, + 0.9414230267760518, + 0.850482533282252, + 0.5162741638881791, + 0.6251121523324751, + 0.9570521142845508, + 0.7832734055464811, + 0.29582701363700126, + 0.8457166262663784, + 0.19574106846162365 + ], + "No change": [ + 0.36997222437408805, + 0.9585835530234085, + 0.8470554196734388, + 0.3119911119732981, + 0.2904953017609012, + 0.6130334390958693, + 0.8426376873177785, + 0.6843389982257562, + 0.936048982222627, + 0.7280329045339358 + ], + "Lose less than 5%": [ + 0.8209221636952363, + 0.604937118181085, + 0.3621831362924902, + 0.4275014936616992, + 0.4899422662160552, + 0.5289337598332745, + 0.8381929329185143, + 0.26884568604415127, + 0.06862739844800403, + 0.7331289564550246 + ], + "Lose more than 5%": [ + 0.2708834110852245, + 0.41973538532900223, + 0.11704248655255234, + 0.8293333189559117, + 0.27394366999516195, + 0.2916462441839135, + 0.80422434080631, + 0.5042365287885517, + 0.27732911702446716, + 0.8883975195301577 + ] + }, + "all": { + "Gain more than 5%": 0.41180290463967284, + "Gain less than 5%": 0.3613604996038956, + "No change": 0.9654456097484817, + "Lose less than 5%": 0.7901626415902006, + "Lose more than 5%": 0.7473865409041579 + } + }, + "labor_supply_response": null, + "constituency_impact": null, + "local_authority_impact": [ + { + "local_authority_code": "E06000000001", + "local_authority_name": "Local Authority 1", + "x": null, + "y": null, + "average_household_income_change": 23.761655080990707, + "relative_household_income_change": 0.45328471316462604, + "population": 103153.12548296218 + } + ], + "congressional_district_impact": null, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json new file mode 100644 index 000000000..f7212bfcd --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json @@ -0,0 +1,1107 @@ +{ + "spm_provenance": null, + "model_version": "2.62.1", + "data_version": "1.30.2", + "budget": { + "tax_revenue_impact": 64390808463.94536, + "state_tax_revenue_impact": 0.0, + "benefit_spending_impact": -2993074413.8109365, + "budgetary_impact": 58668016752.332596, + "households": 125232429.65889315, + "baseline_net_income": 11000903429770.143 + }, + "detailed_budget": { + "income_tax": { + "baseline": 325637125.7675352, + "reform": -59491485.87110997, + "difference": 519461270.1957861 + }, + "national_insurance": { + "baseline": -253679255.8523169, + "reform": 540279671.8759804, + "difference": -454603828.65605855 + }, + "universal_credit": { + "baseline": 603830966.3252072, + "reform": 459649665.2440321, + "difference": -171987117.66939324 + } + }, + "decile": { + "average": { + "1": 229.8313173316613, + "2": 1092.3104477320703, + "3": -1842.0907454154756, + "4": 321.6909929897056, + "5": 1830.7442990938393, + "6": -1406.8736733689877, + "7": 1820.1918576678795, + "8": 1114.1392926026454, + "9": 2065.6939487769896, + "10": -986.5078930451141 + }, + "relative": { + "1": -3.5e-9, + "2": -3.5e-9, + "3": -0.0, + "4": 1e-12, + "5": 0.0, + "6": -0.0, + "7": 1e-12, + "8": -0.0, + "9": 0.514651161252579, + "10": 0.0 + } + }, + "inequality": { + "gini": { + "baseline": 0.6334784504873784, + "reform": 0.24489570857396148 + }, + "top_10_pct_share": { + "baseline": 0.42968860495332184, + "reform": 0.35737961969641396 + }, + "top_1_pct_share": { + "baseline": 0.11958467282121965, + "reform": 0.5001571542030486 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.6786164084469019, + "reform": 0.19912544724959425 + }, + "adult": { + "baseline": 0.6082083930279402, + "reform": 0.26788463676184904 + }, + "senior": { + "baseline": 0.7190033361034402, + "reform": 0.37980670097279756 + }, + "all": { + "baseline": 0.22273872895617441, + "reform": 0.5973431930682456 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.8340038125629663, + "reform": 0.8759846739338617 + }, + "adult": { + "baseline": 0.5221405328253113, + "reform": 0.1887466667447798 + }, + "senior": { + "baseline": 0.16914279771639962, + "reform": 0.6479872117408518 + }, + "all": { + "baseline": 0.5577457838091093, + "reform": 0.6713560120781157 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.6186183415809611, + "reform": 0.3746321527466535 + }, + "female": { + "baseline": 0.5273738308933793, + "reform": 0.2891491959008181 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.6787295490494265, + "reform": 0.48875780443714656 + }, + "female": { + "baseline": 0.1853489385760475, + "reform": 0.5939299668842034 + } + } + }, + "poverty_by_race": null, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.9983275487852417, + 0.09598156795630997, + 0.4412533584632802, + 0.805664919162214, + 0.0890571094472482, + 0.6031814416514121, + 0.8704750455134551, + 0.44611240511532246, + 0.7839082541410588, + 0.38001855310871324 + ], + "Gain less than 5%": [ + 0.07647079614316421, + 0.426420781544818, + 0.3034617506390286, + 0.8165324211795908, + 0.9407251558149671, + 0.192145382959514, + 0.6801797096864858, + 0.4513581729506253, + 0.7226273326875959, + 0.9650738699147708 + ], + "No change": [ + 0.018540926440606675, + 0.5379422837756225, + 0.2511375515864458, + 0.8017780733186686, + 0.18755907011264716, + 0.5027365420127902, + 0.3294401386946224, + 0.5168725569156065, + 0.9815612316919237, + 0.9269299125618775 + ], + "Lose less than 5%": [ + 0.42174289086593053, + 0.4854045828278176, + 0.6024852653943666, + 0.6562875544916017, + 0.4920221130712703, + 0.7212748928180196, + 0.49665032554919664, + 0.6344686285014524, + 0.46726121994398717, + 0.3664770249966022 + ], + "Lose more than 5%": [ + 0.16303107865725508, + 0.9130982750883577, + 0.6194427711966841, + 0.6944523200618581, + 0.2658040323861912, + 0.6460990041366336, + 0.9750146231651212, + 0.7293056383240148, + 0.1581871877358284, + 0.8276901266782712 + ] + }, + "all": { + "Gain more than 5%": 0.5598594695791028, + "Gain less than 5%": 0.623783986638168, + "No change": 0.9646560032450038, + "Lose less than 5%": 0.18257821373725425, + "Lose more than 5%": 0.6099615676222211 + } + }, + "wealth_decile": { + "average": { + "1": 418.61987615264894, + "2": 1092.5765507745975, + "3": -774.5793001944729, + "4": 2895.625186194798, + "5": -15.355742566662167, + "6": 538.4961543504079, + "7": 2515.641832022293, + "8": -2073.6700617943084, + "9": -1384.4805830672783, + "10": -2911.058153713721 + }, + "relative": { + "1": 0.806214673685218, + "2": -3.5e-9, + "3": 1e-12, + "4": -0.0, + "5": -0.0, + "6": -0.0, + "7": 0.0, + "8": 0.0, + "9": -3.5e-9, + "10": 0.0 + } + }, + "intra_wealth_decile": { + "deciles": { + "Gain more than 5%": [ + 0.6291973816666468, + 0.7013704668836823, + 0.12700661731226226, + 0.8326633343412233, + 0.08354357750012409, + 0.6181217255531115, + 0.9113983457176773, + 0.907729408431122, + 0.8932946373096041, + 0.45198385044703215 + ], + "Gain less than 5%": [ + 0.6938003899178132, + 0.7913324805904123, + 0.13707201047427242, + 0.9434018223642858, + 0.9087743307211948, + 0.44088664262863775, + 0.7784980195721916, + 0.44707866429232035, + 0.276407066564499, + 0.7124788208255114 + ], + "No change": [ + 0.06367014967968354, + 0.8001987583321369, + 0.9780769226211543, + 0.2860706698905068, + 0.9396713676591827, + 0.0836817585111066, + 0.8580787872954002, + 0.27980791981603825, + 0.9662205612145367, + 0.43069671425396816 + ], + "Lose less than 5%": [ + 0.9221630049605285, + 0.031913121892390484, + 0.0856434274433241, + 0.8163716167559534, + 0.6385726424936319, + 0.5355011205782659, + 0.03271274819258396, + 0.17329315346763263, + 0.37048865878554826, + 0.052065871297530264 + ], + "Lose more than 5%": [ + 0.9930401276040598, + 0.7651977371516221, + 0.9497969464151493, + 0.700686107822012, + 0.3434781125570455, + 0.07759481040860672, + 0.7315198754952839, + 0.11403501331547539, + 0.5471255342341411, + 0.4640510361121649 + ] + }, + "all": { + "Gain more than 5%": 0.49453074216398074, + "Gain less than 5%": 0.07518152242937215, + "No change": 0.0739710655093323, + "Lose less than 5%": 0.5202668233103811, + "Lose more than 5%": 0.030475714696043932 + } + }, + "labor_supply_response": null, + "constituency_impact": [ + { + "constituency_code": "S14000000001", + "constituency_name": "Constituency 1", + "x": null, + "y": null, + "average_household_income_change": -22.0502560822613, + "relative_household_income_change": 1e-12, + "population": 79332.57320595343 + }, + { + "constituency_code": "S14000000002", + "constituency_name": "Constituency 2", + "x": 1, + "y": 0, + "average_household_income_change": 1215.908995067085, + "relative_household_income_change": 1e-12, + "population": 85934.48703372813 + }, + { + "constituency_code": "S14000000003", + "constituency_name": "Constituency 3", + "x": 2, + "y": 0, + "average_household_income_change": -377.999828418476, + "relative_household_income_change": -0.0, + "population": 83465.39782866623 + }, + { + "constituency_code": "S14000000004", + "constituency_name": "Constituency 4", + "x": 3, + "y": 0, + "average_household_income_change": -1974.7197361654237, + "relative_household_income_change": 0.0, + "population": 113477.96855441103 + }, + { + "constituency_code": "S14000000005", + "constituency_name": "Constituency 5", + "x": 4, + "y": 0, + "average_household_income_change": -649.8105518108947, + "relative_household_income_change": -0.0, + "population": 62789.23357183145 + }, + { + "constituency_code": "S14000000006", + "constituency_name": "Constituency 6", + "x": 5, + "y": 0, + "average_household_income_change": -387.339732156089, + "relative_household_income_change": 1e-12, + "population": 94065.14389717858 + }, + { + "constituency_code": "S14000000007", + "constituency_name": "Constituency 7", + "x": 6, + "y": 0, + "average_household_income_change": -726.0333413494302, + "relative_household_income_change": -0.0, + "population": 128189.16865653818 + }, + { + "constituency_code": "S14000000008", + "constituency_name": "Constituency 8", + "x": null, + "y": null, + "average_household_income_change": 459.8825949063641, + "relative_household_income_change": -0.0, + "population": 63046.60303942481 + }, + { + "constituency_code": "S14000000009", + "constituency_name": "Constituency 9", + "x": 8, + "y": 0, + "average_household_income_change": -1766.27589639709, + "relative_household_income_change": 0.0, + "population": 103944.30710763739 + }, + { + "constituency_code": "S14000000010", + "constituency_name": "Constituency 10", + "x": 9, + "y": 0, + "average_household_income_change": -1656.9844230967562, + "relative_household_income_change": 0.0, + "population": 92293.1508555326 + }, + { + "constituency_code": "S14000000011", + "constituency_name": "Constituency 11", + "x": 10, + "y": 0, + "average_household_income_change": -1568.4105912709422, + "relative_household_income_change": 0.0, + "population": 89367.41666308911 + }, + { + "constituency_code": "S14000000012", + "constituency_name": "Constituency 12", + "x": 11, + "y": 0, + "average_household_income_change": -1540.3301530676376, + "relative_household_income_change": 1e-12, + "population": 53137.15027763359 + }, + { + "constituency_code": "S14000000013", + "constituency_name": "Constituency 13", + "x": 12, + "y": 0, + "average_household_income_change": -49.71783554546061, + "relative_household_income_change": -3.5e-9, + "population": 69494.2870251734 + }, + { + "constituency_code": "S14000000014", + "constituency_name": "Constituency 14", + "x": 13, + "y": 0, + "average_household_income_change": -1713.485408027081, + "relative_household_income_change": 1e-12, + "population": 114944.10521441708 + }, + { + "constituency_code": "S14000000015", + "constituency_name": "Constituency 15", + "x": null, + "y": null, + "average_household_income_change": 515.040158455704, + "relative_household_income_change": 1e-12, + "population": 123680.0940363324 + }, + { + "constituency_code": "S14000000016", + "constituency_name": "Constituency 16", + "x": 15, + "y": 0, + "average_household_income_change": 886.6824618994942, + "relative_household_income_change": -0.0, + "population": 133563.797744863 + }, + { + "constituency_code": "S14000000017", + "constituency_name": "Constituency 17", + "x": 16, + "y": 0, + "average_household_income_change": 1847.8852194008336, + "relative_household_income_change": 0.970116107552746, + "population": 96561.24036341548 + }, + { + "constituency_code": "S14000000018", + "constituency_name": "Constituency 18", + "x": 17, + "y": 0, + "average_household_income_change": -199.63383446378157, + "relative_household_income_change": -0.0, + "population": 130680.98864876162 + }, + { + "constituency_code": "S14000000019", + "constituency_name": "Constituency 19", + "x": 18, + "y": 0, + "average_household_income_change": -1286.6748534572503, + "relative_household_income_change": -3.5e-9, + "population": 73823.59031650981 + }, + { + "constituency_code": "S14000000020", + "constituency_name": "Constituency 20", + "x": 19, + "y": 0, + "average_household_income_change": -1170.758388551763, + "relative_household_income_change": 0.8412079197691499, + "population": 95406.04234472507 + }, + { + "constituency_code": "S14000000021", + "constituency_name": "Constituency 21", + "x": 20, + "y": 0, + "average_household_income_change": -721.0569534926518, + "relative_household_income_change": -0.0, + "population": 68215.09086082038 + }, + { + "constituency_code": "S14000000022", + "constituency_name": "Constituency 22", + "x": null, + "y": null, + "average_household_income_change": -1327.8413593194002, + "relative_household_income_change": 1e-12, + "population": 139680.9189915626 + }, + { + "constituency_code": "S14000000023", + "constituency_name": "Constituency 23", + "x": 22, + "y": 0, + "average_household_income_change": 1823.701833262151, + "relative_household_income_change": 0.0, + "population": 135075.91381560973 + }, + { + "constituency_code": "S14000000024", + "constituency_name": "Constituency 24", + "x": 23, + "y": 0, + "average_household_income_change": 585.4829596118924, + "relative_household_income_change": 0.0, + "population": 62805.9120249973 + }, + { + "constituency_code": "S14000000025", + "constituency_name": "Constituency 25", + "x": 24, + "y": 0, + "average_household_income_change": -10.380093653008071, + "relative_household_income_change": 1e-12, + "population": 93527.3783158783 + }, + { + "constituency_code": "S14000000026", + "constituency_name": "Constituency 26", + "x": 25, + "y": 0, + "average_household_income_change": -256.4312080701563, + "relative_household_income_change": 0.6432241717762336, + "population": 88101.31906153599 + }, + { + "constituency_code": "S14000000027", + "constituency_name": "Constituency 27", + "x": 26, + "y": 0, + "average_household_income_change": 1702.7522243659537, + "relative_household_income_change": 0.0, + "population": 125414.43486372594 + }, + { + "constituency_code": "S14000000028", + "constituency_name": "Constituency 28", + "x": 27, + "y": 0, + "average_household_income_change": 1224.4288663412387, + "relative_household_income_change": 1e-12, + "population": 125936.64670777883 + }, + { + "constituency_code": "S14000000029", + "constituency_name": "Constituency 29", + "x": null, + "y": null, + "average_household_income_change": -50.36433936503304, + "relative_household_income_change": 0.5045905570059873, + "population": 110678.73316985903 + }, + { + "constituency_code": "S14000000030", + "constituency_name": "Constituency 30", + "x": 29, + "y": 0, + "average_household_income_change": -1787.784293656903, + "relative_household_income_change": -3.5e-9, + "population": 137815.36506324858 + }, + { + "constituency_code": "S14000000031", + "constituency_name": "Constituency 31", + "x": 30, + "y": 0, + "average_household_income_change": -712.4863332789974, + "relative_household_income_change": 0.8140841787567573, + "population": 77978.11324744608 + }, + { + "constituency_code": "S14000000032", + "constituency_name": "Constituency 32", + "x": 0, + "y": 1, + "average_household_income_change": 1623.627170035723, + "relative_household_income_change": -0.0, + "population": 106220.79881424447 + }, + { + "constituency_code": "S14000000033", + "constituency_name": "Constituency 33", + "x": 1, + "y": 1, + "average_household_income_change": -879.6243473802599, + "relative_household_income_change": -0.0, + "population": 113212.96419666243 + }, + { + "constituency_code": "S14000000034", + "constituency_name": "Constituency 34", + "x": 2, + "y": 1, + "average_household_income_change": -759.2250650364525, + "relative_household_income_change": -0.0, + "population": 75244.09885972543 + }, + { + "constituency_code": "S14000000035", + "constituency_name": "Constituency 35", + "x": 3, + "y": 1, + "average_household_income_change": 328.0589704575538, + "relative_household_income_change": -0.0, + "population": 100810.42044062808 + }, + { + "constituency_code": "S14000000036", + "constituency_name": "Constituency 36", + "x": null, + "y": null, + "average_household_income_change": 1123.594288313716, + "relative_household_income_change": 1e-12, + "population": 124050.46952325002 + }, + { + "constituency_code": "S14000000037", + "constituency_name": "Constituency 37", + "x": 5, + "y": 1, + "average_household_income_change": -207.40824634245314, + "relative_household_income_change": 0.566121478909492, + "population": 86172.31704600062 + }, + { + "constituency_code": "S14000000038", + "constituency_name": "Constituency 38", + "x": 6, + "y": 1, + "average_household_income_change": 450.9534054524922, + "relative_household_income_change": 1e-12, + "population": 71202.13598416587 + }, + { + "constituency_code": "S14000000039", + "constituency_name": "Constituency 39", + "x": 7, + "y": 1, + "average_household_income_change": -505.1320248700919, + "relative_household_income_change": -0.0, + "population": 78380.02565944156 + }, + { + "constituency_code": "S14000000040", + "constituency_name": "Constituency 40", + "x": 8, + "y": 1, + "average_household_income_change": 369.07046484015103, + "relative_household_income_change": 0.0, + "population": 96559.91677812286 + }, + { + "constituency_code": "S14000000041", + "constituency_name": "Constituency 41", + "x": 9, + "y": 1, + "average_household_income_change": 1725.916837222794, + "relative_household_income_change": 0.6377702114609652, + "population": 54537.937931824395 + }, + { + "constituency_code": "S14000000042", + "constituency_name": "Constituency 42", + "x": 10, + "y": 1, + "average_household_income_change": 1330.2517858463264, + "relative_household_income_change": 0.5598026330340823, + "population": 52934.12546599741 + }, + { + "constituency_code": "S14000000043", + "constituency_name": "Constituency 43", + "x": null, + "y": null, + "average_household_income_change": 1766.9186377099031, + "relative_household_income_change": 1e-12, + "population": 67014.41931048906 + }, + { + "constituency_code": "S14000000044", + "constituency_name": "Constituency 44", + "x": 12, + "y": 1, + "average_household_income_change": -573.5210003270792, + "relative_household_income_change": 0.5958518145639365, + "population": 143726.2105377167 + }, + { + "constituency_code": "S14000000045", + "constituency_name": "Constituency 45", + "x": 13, + "y": 1, + "average_household_income_change": -1142.3319096547716, + "relative_household_income_change": 1e-12, + "population": 127568.64904169193 + }, + { + "constituency_code": "S14000000046", + "constituency_name": "Constituency 46", + "x": 14, + "y": 1, + "average_household_income_change": -1987.0210639986383, + "relative_household_income_change": -0.0, + "population": 79684.65150836893 + }, + { + "constituency_code": "S14000000047", + "constituency_name": "Constituency 47", + "x": 15, + "y": 1, + "average_household_income_change": -899.9641960071924, + "relative_household_income_change": 0.33535050184635984, + "population": 110975.7867195766 + }, + { + "constituency_code": "S14000000048", + "constituency_name": "Constituency 48", + "x": 16, + "y": 1, + "average_household_income_change": 43.803712052223446, + "relative_household_income_change": -0.0, + "population": 55638.22656504041 + }, + { + "constituency_code": "S14000000049", + "constituency_name": "Constituency 49", + "x": 17, + "y": 1, + "average_household_income_change": -1858.2148152893594, + "relative_household_income_change": -0.0, + "population": 61494.55783779679 + }, + { + "constituency_code": "S14000000050", + "constituency_name": "Constituency 50", + "x": null, + "y": null, + "average_household_income_change": 1793.552834263271, + "relative_household_income_change": 1e-12, + "population": 71150.5956498904 + }, + { + "constituency_code": "S14000000051", + "constituency_name": "Constituency 51", + "x": 19, + "y": 1, + "average_household_income_change": 395.1686018800302, + "relative_household_income_change": -3.5e-9, + "population": 57577.16533695534 + }, + { + "constituency_code": "S14000000052", + "constituency_name": "Constituency 52", + "x": 20, + "y": 1, + "average_household_income_change": 1734.0747439322786, + "relative_household_income_change": 1e-12, + "population": 65718.41606608071 + }, + { + "constituency_code": "S14000000053", + "constituency_name": "Constituency 53", + "x": 21, + "y": 1, + "average_household_income_change": -1021.0283577089085, + "relative_household_income_change": 1e-12, + "population": 52738.38868454951 + }, + { + "constituency_code": "S14000000054", + "constituency_name": "Constituency 54", + "x": 22, + "y": 1, + "average_household_income_change": 1195.60033690023, + "relative_household_income_change": 0.0, + "population": 58687.69580485995 + }, + { + "constituency_code": "S14000000055", + "constituency_name": "Constituency 55", + "x": 23, + "y": 1, + "average_household_income_change": 1258.2528562824318, + "relative_household_income_change": 1e-12, + "population": 52638.640102357116 + }, + { + "constituency_code": "S14000000056", + "constituency_name": "Constituency 56", + "x": 24, + "y": 1, + "average_household_income_change": -1190.9949809860163, + "relative_household_income_change": -3.5e-9, + "population": 95505.81052752634 + }, + { + "constituency_code": "S14000000057", + "constituency_name": "Constituency 57", + "x": null, + "y": null, + "average_household_income_change": -1580.6126310753368, + "relative_household_income_change": 0.8216310930495441, + "population": 64646.038428561056 + } + ], + "local_authority_impact": [ + { + "local_authority_code": "S12000000001", + "local_authority_name": "Local Authority 1", + "x": null, + "y": null, + "average_household_income_change": 1711.9662635106972, + "relative_household_income_change": -0.0, + "population": 139245.28916534188 + }, + { + "local_authority_code": "S12000000002", + "local_authority_name": "Local Authority 2", + "x": 1, + "y": 0, + "average_household_income_change": -760.4907483529573, + "relative_household_income_change": -0.0, + "population": 100805.127015022 + }, + { + "local_authority_code": "S12000000003", + "local_authority_name": "Local Authority 3", + "x": 2, + "y": 0, + "average_household_income_change": -1353.896856076334, + "relative_household_income_change": -0.0, + "population": 136496.49703708116 + }, + { + "local_authority_code": "S12000000004", + "local_authority_name": "Local Authority 4", + "x": 3, + "y": 0, + "average_household_income_change": -1879.016683323623, + "relative_household_income_change": 0.0, + "population": 127529.863915214 + }, + { + "local_authority_code": "S12000000005", + "local_authority_name": "Local Authority 5", + "x": 4, + "y": 0, + "average_household_income_change": -629.0130927662503, + "relative_household_income_change": -0.0, + "population": 119156.76386104178 + }, + { + "local_authority_code": "S12000000006", + "local_authority_name": "Local Authority 6", + "x": 5, + "y": 0, + "average_household_income_change": 874.6099793547413, + "relative_household_income_change": -3.5e-9, + "population": 106277.9233003826 + }, + { + "local_authority_code": "S12000000007", + "local_authority_name": "Local Authority 7", + "x": 6, + "y": 0, + "average_household_income_change": 467.98635714846614, + "relative_household_income_change": 1e-12, + "population": 139741.54468077584 + }, + { + "local_authority_code": "S12000000008", + "local_authority_name": "Local Authority 8", + "x": null, + "y": null, + "average_household_income_change": -550.7700901310177, + "relative_household_income_change": 0.0, + "population": 80739.55317161283 + }, + { + "local_authority_code": "S12000000009", + "local_authority_name": "Local Authority 9", + "x": 8, + "y": 0, + "average_household_income_change": -838.1639537967227, + "relative_household_income_change": 0.42674518552708585, + "population": 77962.94927239053 + }, + { + "local_authority_code": "S12000000010", + "local_authority_name": "Local Authority 10", + "x": 9, + "y": 0, + "average_household_income_change": -1308.258634844165, + "relative_household_income_change": 0.7317258270864375, + "population": 106985.32281751494 + }, + { + "local_authority_code": "S12000000011", + "local_authority_name": "Local Authority 11", + "x": 10, + "y": 0, + "average_household_income_change": -1200.3987427447091, + "relative_household_income_change": -0.0, + "population": 60169.16904360067 + }, + { + "local_authority_code": "S12000000012", + "local_authority_name": "Local Authority 12", + "x": 11, + "y": 0, + "average_household_income_change": -538.9775499790547, + "relative_household_income_change": 1e-12, + "population": 134657.14469719745 + }, + { + "local_authority_code": "S12000000013", + "local_authority_name": "Local Authority 13", + "x": 12, + "y": 0, + "average_household_income_change": -521.6966583670524, + "relative_household_income_change": -0.0, + "population": 84326.3910283849 + }, + { + "local_authority_code": "S12000000014", + "local_authority_name": "Local Authority 14", + "x": 13, + "y": 0, + "average_household_income_change": -403.72167393983773, + "relative_household_income_change": 1e-12, + "population": 90121.64285325728 + }, + { + "local_authority_code": "S12000000015", + "local_authority_name": "Local Authority 15", + "x": null, + "y": null, + "average_household_income_change": -1992.3987578717731, + "relative_household_income_change": 0.5390980006995861, + "population": 57146.25648990415 + }, + { + "local_authority_code": "S12000000016", + "local_authority_name": "Local Authority 16", + "x": 15, + "y": 0, + "average_household_income_change": 1912.9330818653293, + "relative_household_income_change": -3.5e-9, + "population": 61630.540132526454 + }, + { + "local_authority_code": "S12000000017", + "local_authority_name": "Local Authority 17", + "x": 16, + "y": 0, + "average_household_income_change": 676.9488735769369, + "relative_household_income_change": 0.22230810403099188, + "population": 83264.51133432586 + }, + { + "local_authority_code": "S12000000018", + "local_authority_name": "Local Authority 18", + "x": 17, + "y": 0, + "average_household_income_change": 736.3951699342284, + "relative_household_income_change": -3.5e-9, + "population": 60421.1365110476 + }, + { + "local_authority_code": "S12000000019", + "local_authority_name": "Local Authority 19", + "x": 18, + "y": 0, + "average_household_income_change": -892.5830346459045, + "relative_household_income_change": -3.5e-9, + "population": 149136.34982537729 + }, + { + "local_authority_code": "S12000000020", + "local_authority_name": "Local Authority 20", + "x": 19, + "y": 0, + "average_household_income_change": 1871.832185732079, + "relative_household_income_change": 0.9315108544036715, + "population": 82551.14096226152 + }, + { + "local_authority_code": "S12000000021", + "local_authority_name": "Local Authority 21", + "x": 20, + "y": 0, + "average_household_income_change": -1775.413352074153, + "relative_household_income_change": -3.5e-9, + "population": 142543.40081999943 + }, + { + "local_authority_code": "S12000000022", + "local_authority_name": "Local Authority 22", + "x": null, + "y": null, + "average_household_income_change": 1677.2264729092446, + "relative_household_income_change": -3.5e-9, + "population": 119213.98806155729 + }, + { + "local_authority_code": "S12000000023", + "local_authority_name": "Local Authority 23", + "x": 22, + "y": 0, + "average_household_income_change": 253.80086043629467, + "relative_household_income_change": 0.16846215105615936, + "population": 131349.02471522414 + }, + { + "local_authority_code": "S12000000024", + "local_authority_name": "Local Authority 24", + "x": 23, + "y": 0, + "average_household_income_change": -1851.6056943936076, + "relative_household_income_change": -3.5e-9, + "population": 98606.58597465903 + }, + { + "local_authority_code": "S12000000025", + "local_authority_name": "Local Authority 25", + "x": 24, + "y": 0, + "average_household_income_change": -1449.2733170436777, + "relative_household_income_change": 1e-12, + "population": 57564.83061204634 + }, + { + "local_authority_code": "S12000000026", + "local_authority_name": "Local Authority 26", + "x": 25, + "y": 0, + "average_household_income_change": -872.6789126607173, + "relative_household_income_change": 1e-12, + "population": 125783.43975305068 + }, + { + "local_authority_code": "S12000000027", + "local_authority_name": "Local Authority 27", + "x": 26, + "y": 0, + "average_household_income_change": -1311.9645179858242, + "relative_household_income_change": 0.9912178495741759, + "population": 81237.5561149096 + }, + { + "local_authority_code": "S12000000028", + "local_authority_name": "Local Authority 28", + "x": 27, + "y": 0, + "average_household_income_change": 576.031133075289, + "relative_household_income_change": 0.7038678747725524, + "population": 141107.730757162 + }, + { + "local_authority_code": "S12000000029", + "local_authority_name": "Local Authority 29", + "x": null, + "y": null, + "average_household_income_change": 958.8442127965027, + "relative_household_income_change": 1e-12, + "population": 102312.94874371101 + }, + { + "local_authority_code": "S12000000030", + "local_authority_name": "Local Authority 30", + "x": 29, + "y": 0, + "average_household_income_change": -1343.8459777407186, + "relative_household_income_change": -3.5e-9, + "population": 103091.15965651689 + }, + { + "local_authority_code": "S12000000031", + "local_authority_name": "Local Authority 31", + "x": 30, + "y": 0, + "average_household_income_change": -26.106472316017815, + "relative_household_income_change": -3.5e-9, + "population": 139125.5993482639 + }, + { + "local_authority_code": "S12000000032", + "local_authority_name": "Local Authority 32", + "x": 0, + "y": 1, + "average_household_income_change": 1875.5073255172429, + "relative_household_income_change": 1e-12, + "population": 52821.895044486846 + } + ], + "congressional_district_impact": null, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json new file mode 100644 index 000000000..726c2d974 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json @@ -0,0 +1,9466 @@ +{ + "spm_provenance": null, + "model_version": "2.62.1", + "data_version": "1.30.2", + "budget": { + "tax_revenue_impact": 48357397852.14588, + "state_tax_revenue_impact": 0.0, + "benefit_spending_impact": 59038713113.13933, + "budgetary_impact": 24580338977.940384, + "households": 138849005.675541, + "baseline_net_income": 12479797149479.861 + }, + "detailed_budget": { + "income_tax": { + "baseline": 844649993.330834, + "reform": -941989543.4327705, + "difference": -68754691.2437893 + }, + "national_insurance": { + "baseline": 886713433.9966273, + "reform": 297949106.2738484, + "difference": 801800983.5012454 + }, + "universal_credit": { + "baseline": -773588070.6937113, + "reform": -61861904.43567252, + "difference": -506854334.7603394 + } + }, + "decile": { + "average": { + "1": 262.5651554155826, + "2": 443.6471275686047, + "3": -2921.314862466587, + "4": -1699.621197216911, + "5": -1323.1058039333382, + "6": 2498.0722308513114, + "7": 1594.3527097748504, + "8": -2042.3747258517706, + "9": 1782.8819485872268, + "10": -2167.395489606581 + }, + "relative": { + "1": -0.0, + "2": 0.0, + "3": -0.0, + "4": -0.0, + "5": 1e-12, + "6": -0.0, + "7": -0.0, + "8": -0.0, + "9": -3.5e-9, + "10": 1e-12 + } + }, + "inequality": { + "gini": { + "baseline": 0.4149067116227452, + "reform": 0.9357926558382951 + }, + "top_10_pct_share": { + "baseline": 0.26381193894997024, + "reform": 0.33185448722063815 + }, + "top_1_pct_share": { + "baseline": 0.8174003109567611, + "reform": 0.5861412585033708 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.5959789505447018, + "reform": 0.7076761524222557 + }, + "adult": { + "baseline": 0.06602245055107714, + "reform": 0.35535135729996914 + }, + "senior": { + "baseline": 0.3060642591174333, + "reform": 0.6964198076411567 + }, + "all": { + "baseline": 0.18477324009849416, + "reform": 0.47253834981224785 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.17612008047849625, + "reform": 0.25618183594271204 + }, + "adult": { + "baseline": 0.939011804929864, + "reform": 0.9492204766705261 + }, + "senior": { + "baseline": 0.35764406505883395, + "reform": 0.4042940811668897 + }, + "all": { + "baseline": 0.5490659459511809, + "reform": 0.4188272384269869 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.37636541261165457, + "reform": 0.8412340954622506 + }, + "female": { + "baseline": 0.45282225345616867, + "reform": 0.7077582566508612 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.6237130310772351, + "reform": 0.954394476089788 + }, + "female": { + "baseline": 0.11902055707377501, + "reform": 0.24608679063361372 + } + } + }, + "poverty_by_race": null, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.8194931666705297, + 0.46220536041544236, + 0.5124960121124476, + 0.8927110919706659, + 0.25089395624103406, + 0.46284975937048245, + 0.5897633712619398, + 0.7809871494563421, + 0.3672972162045115, + 0.29586380747431196 + ], + "Gain less than 5%": [ + 0.43297668506056197, + 0.9783414907096316, + 0.20848339304879704, + 0.5128800541969205, + 0.3626254865384637, + 0.14819563433497784, + 0.27567637655286203, + 0.702606734224737, + 0.09207334602214667, + 0.6859658309578894 + ], + "No change": [ + 0.30617106838135244, + 0.7987736171349602, + 0.6268057685261074, + 0.7211350402919544, + 0.30930358169024297, + 0.4837937739605416, + 0.7201288197081697, + 0.08095823218449283, + 0.6006492118055428, + 0.9236068159780504 + ], + "Lose less than 5%": [ + 0.03182648299140178, + 0.7405584945824758, + 0.34383290131610267, + 0.2502088580702625, + 0.6509664388131893, + 0.14562901896020186, + 0.9790506207650106, + 0.6383243727141709, + 0.8016644306001379, + 0.3341852936031615 + ], + "Lose more than 5%": [ + 0.2070598910046504, + 0.9813004861151011, + 0.5640476118758213, + 0.13277698777913516, + 0.896496018678021, + 0.41382241328530467, + 0.16882086123939744, + 0.37299762777421874, + 0.05877061817596774, + 0.4206458203108081 + ] + }, + "all": { + "Gain more than 5%": 0.14120240847136756, + "Gain less than 5%": 0.9220033436961468, + "No change": 0.620401984842514, + "Lose less than 5%": 0.16928990912443076, + "Lose more than 5%": 0.5221943516547977 + } + }, + "wealth_decile": { + "average": { + "1": 2760.7880601954007, + "2": 1133.034276791141, + "3": -1093.786486430059, + "4": -1355.7868171771192, + "5": -179.51504571187195, + "6": 2411.162815086828, + "7": -2324.637902811137, + "8": 1932.3300267035227, + "9": 190.5111354507152, + "10": -1923.6226099596192 + }, + "relative": { + "1": 1e-12, + "2": -3.5e-9, + "3": 0.2723101341365193, + "4": 1e-12, + "5": 1e-12, + "6": 0.0, + "7": 1e-12, + "8": 1e-12, + "9": 1e-12, + "10": 1e-12 + } + }, + "intra_wealth_decile": { + "deciles": { + "Gain more than 5%": [ + 0.9568854268308546, + 0.3400733891851615, + 0.17864939257994905, + 0.8518219963578928, + 0.47430384566628103, + 0.7737170178494772, + 0.3259049331519225, + 0.27368510859414175, + 0.28882651639491064, + 0.5003491241293917 + ], + "Gain less than 5%": [ + 0.6736381411375548, + 0.3565706338575818, + 0.2738756510918071, + 0.3455818350451242, + 0.9375151830031623, + 0.4084690239283826, + 0.9223332318878142, + 0.1724730198823753, + 0.8669714680935215, + 0.4497840700754686 + ], + "No change": [ + 0.3642746877410745, + 0.3343392574648516, + 0.14185820914313252, + 0.980204821871017, + 0.19865319756135547, + 0.36202891869382325, + 0.8538547938856402, + 0.2828581980722068, + 0.07883672952418375, + 0.6708582992133008 + ], + "Lose less than 5%": [ + 0.4166333688186612, + 0.6167804507938893, + 0.9362248151067294, + 0.5164551859017925, + 0.9364310981392266, + 0.3026660950760326, + 0.553207820465451, + 0.8671281385188301, + 0.27070607036660954, + 0.9960168155105562 + ], + "Lose more than 5%": [ + 0.1955988568069269, + 0.5864482182343931, + 0.6240718189635653, + 0.18074274971364557, + 0.7589971548762711, + 0.18053314559806255, + 0.7184623333950467, + 0.9864683931439034, + 0.9797599556017115, + 0.1657454822521135 + ] + }, + "all": { + "Gain more than 5%": 0.891809859376601, + "Gain less than 5%": 0.11082883415632572, + "No change": 0.9679805128391624, + "Lose less than 5%": 0.48266186709225245, + "Lose more than 5%": 0.5483207407270189 + } + }, + "labor_supply_response": { + "substitution_lsr": -512964474.581326, + "income_lsr": 4417979.006311912, + "relative_lsr": { + "income": -0.0, + "substitution": 1e-12 + }, + "total_change": 852714849.5207293, + "revenue_change": -205242491.53897566, + "decile": { + "average": { + "income": { + "1": 935.7357839289454, + "2": 462.94383251575465, + "3": 96.23021667461119, + "4": 257.8467628719523, + "5": 172.12874681714885, + "6": -279.55778856918556, + "7": 993.4358936204936, + "8": -192.21213911130343, + "9": 967.6904232442824, + "10": 705.1466422363058 + }, + "substitution": { + "1": -943.3107891837828, + "2": -483.5956687839016, + "3": -44.47179025453374, + "4": 629.1927835860174, + "5": -644.736925553173, + "6": 231.68117254507402, + "7": -265.58121369712893, + "8": 571.3578431812651, + "9": 852.8503903153766, + "10": 524.2353774411259 + } + }, + "relative": { + "income": { + "1": -613.4186720210399, + "2": 520.5759410481696, + "3": -123.67540662945076, + "4": 674.444469332411, + "5": -59.755876469413934, + "6": 813.2868269175722, + "7": 972.3328516817755, + "8": 648.4356491100871, + "9": 652.161311150754, + "10": 638.0759148741213 + }, + "substitution": { + "1": 184.25451379479486, + "2": -650.2800501994182, + "3": 972.4071166773432, + "4": -71.64740663837277, + "5": -967.6036583871357, + "6": -131.08238678931406, + "7": -170.0468523564045, + "8": -598.2616845096542, + "9": 920.7243378432994, + "10": -775.9508418317278 + } + } + }, + "hours": { + "baseline": 8082872177.716915, + "reform": 995217975.6411117, + "change": -6427763.165620004, + "income_effect": -2834583.8204234396, + "substitution_effect": -993619.1893709556 + } + }, + "constituency_impact": [ + { + "constituency_code": "E14000000001", + "constituency_name": "Constituency 1", + "x": null, + "y": null, + "average_household_income_change": 1913.4194334839867, + "relative_household_income_change": 0.05452265501045739, + "population": 98386.17433244424 + }, + { + "constituency_code": "E14000000002", + "constituency_name": "Constituency 2", + "x": 1, + "y": 0, + "average_household_income_change": 1766.892980665629, + "relative_household_income_change": -0.0, + "population": 65914.779888391 + }, + { + "constituency_code": "E14000000003", + "constituency_name": "Constituency 3", + "x": 2, + "y": 0, + "average_household_income_change": 1979.1207253554358, + "relative_household_income_change": -0.0, + "population": 146525.5307749652 + }, + { + "constituency_code": "E14000000004", + "constituency_name": "Constituency 4", + "x": 3, + "y": 0, + "average_household_income_change": -241.7288234592716, + "relative_household_income_change": 0.0011309834882184955, + "population": 88718.4004768281 + }, + { + "constituency_code": "E14000000005", + "constituency_name": "Constituency 5", + "x": 4, + "y": 0, + "average_household_income_change": -1955.5874396191487, + "relative_household_income_change": -0.0, + "population": 98185.74579851818 + }, + { + "constituency_code": "E14000000006", + "constituency_name": "Constituency 6", + "x": 5, + "y": 0, + "average_household_income_change": 500.32029373535636, + "relative_household_income_change": -3.5e-9, + "population": 91600.07992596942 + }, + { + "constituency_code": "E14000000007", + "constituency_name": "Constituency 7", + "x": 6, + "y": 0, + "average_household_income_change": 1429.7516356012716, + "relative_household_income_change": -3.5e-9, + "population": 144767.61377682418 + }, + { + "constituency_code": "E14000000008", + "constituency_name": "Constituency 8", + "x": null, + "y": null, + "average_household_income_change": 303.4651099150256, + "relative_household_income_change": 1e-12, + "population": 104370.43989569198 + }, + { + "constituency_code": "E14000000009", + "constituency_name": "Constituency 9", + "x": 8, + "y": 0, + "average_household_income_change": -1430.855530589559, + "relative_household_income_change": 0.5204399501519741, + "population": 120355.88560972406 + }, + { + "constituency_code": "E14000000010", + "constituency_name": "Constituency 10", + "x": 9, + "y": 0, + "average_household_income_change": -879.9490006950084, + "relative_household_income_change": -3.5e-9, + "population": 131238.10258698944 + }, + { + "constituency_code": "E14000000011", + "constituency_name": "Constituency 11", + "x": 10, + "y": 0, + "average_household_income_change": 626.256099809495, + "relative_household_income_change": 0.0, + "population": 70186.41755304152 + }, + { + "constituency_code": "E14000000012", + "constituency_name": "Constituency 12", + "x": 11, + "y": 0, + "average_household_income_change": -1182.6118333676216, + "relative_household_income_change": -3.5e-9, + "population": 95668.57228708114 + }, + { + "constituency_code": "E14000000013", + "constituency_name": "Constituency 13", + "x": 12, + "y": 0, + "average_household_income_change": -1313.786038062641, + "relative_household_income_change": -0.0, + "population": 58717.759794404556 + }, + { + "constituency_code": "E14000000014", + "constituency_name": "Constituency 14", + "x": 13, + "y": 0, + "average_household_income_change": 522.77933357376, + "relative_household_income_change": -0.0, + "population": 105485.06719596757 + }, + { + "constituency_code": "E14000000015", + "constituency_name": "Constituency 15", + "x": null, + "y": null, + "average_household_income_change": -283.01292441674696, + "relative_household_income_change": -0.0, + "population": 64972.126858879885 + }, + { + "constituency_code": "E14000000016", + "constituency_name": "Constituency 16", + "x": 15, + "y": 0, + "average_household_income_change": 1838.9633545881009, + "relative_household_income_change": 0.8530915769275014, + "population": 95302.11433575382 + }, + { + "constituency_code": "E14000000017", + "constituency_name": "Constituency 17", + "x": 16, + "y": 0, + "average_household_income_change": 1807.801459835364, + "relative_household_income_change": -0.0, + "population": 102662.03335442903 + }, + { + "constituency_code": "E14000000018", + "constituency_name": "Constituency 18", + "x": 17, + "y": 0, + "average_household_income_change": 1302.60169056573, + "relative_household_income_change": -3.5e-9, + "population": 102906.71208383882 + }, + { + "constituency_code": "E14000000019", + "constituency_name": "Constituency 19", + "x": 18, + "y": 0, + "average_household_income_change": 684.7633481422735, + "relative_household_income_change": -0.0, + "population": 54222.044888242985 + }, + { + "constituency_code": "E14000000020", + "constituency_name": "Constituency 20", + "x": 19, + "y": 0, + "average_household_income_change": 488.326844642812, + "relative_household_income_change": 1e-12, + "population": 57009.64734391624 + }, + { + "constituency_code": "E14000000021", + "constituency_name": "Constituency 21", + "x": 20, + "y": 0, + "average_household_income_change": -1826.7030677545897, + "relative_household_income_change": -0.0, + "population": 60607.39438166653 + }, + { + "constituency_code": "E14000000022", + "constituency_name": "Constituency 22", + "x": null, + "y": null, + "average_household_income_change": 941.3762870336742, + "relative_household_income_change": 0.0, + "population": 65082.17476365694 + }, + { + "constituency_code": "E14000000023", + "constituency_name": "Constituency 23", + "x": 22, + "y": 0, + "average_household_income_change": -166.75177258327656, + "relative_household_income_change": 0.046193690494361284, + "population": 65296.52386416295 + }, + { + "constituency_code": "E14000000024", + "constituency_name": "Constituency 24", + "x": 23, + "y": 0, + "average_household_income_change": -1397.6894273125588, + "relative_household_income_change": -0.0, + "population": 82358.07690579105 + }, + { + "constituency_code": "E14000000025", + "constituency_name": "Constituency 25", + "x": 24, + "y": 0, + "average_household_income_change": -1166.7618642594055, + "relative_household_income_change": -0.0, + "population": 58056.9265454936 + }, + { + "constituency_code": "E14000000026", + "constituency_name": "Constituency 26", + "x": 25, + "y": 0, + "average_household_income_change": 1103.7650090255484, + "relative_household_income_change": 0.0, + "population": 138706.63023281284 + }, + { + "constituency_code": "E14000000027", + "constituency_name": "Constituency 27", + "x": 26, + "y": 0, + "average_household_income_change": 1390.0054628643597, + "relative_household_income_change": 0.19212060592154867, + "population": 82600.12292622661 + }, + { + "constituency_code": "E14000000028", + "constituency_name": "Constituency 28", + "x": 27, + "y": 0, + "average_household_income_change": -550.2630807394695, + "relative_household_income_change": 0.0, + "population": 66882.2129669484 + }, + { + "constituency_code": "E14000000029", + "constituency_name": "Constituency 29", + "x": null, + "y": null, + "average_household_income_change": -376.0757965327994, + "relative_household_income_change": -0.0, + "population": 131755.4233245176 + }, + { + "constituency_code": "E14000000030", + "constituency_name": "Constituency 30", + "x": 29, + "y": 0, + "average_household_income_change": -1807.9185090422197, + "relative_household_income_change": 1e-12, + "population": 76098.68374829671 + }, + { + "constituency_code": "E14000000031", + "constituency_name": "Constituency 31", + "x": 30, + "y": 0, + "average_household_income_change": 1181.9497773458966, + "relative_household_income_change": 0.6841082101901497, + "population": 97597.70307584907 + }, + { + "constituency_code": "E14000000032", + "constituency_name": "Constituency 32", + "x": 0, + "y": 1, + "average_household_income_change": -1978.4430390331474, + "relative_household_income_change": 0.9698872181871829, + "population": 119385.14894333272 + }, + { + "constituency_code": "E14000000033", + "constituency_name": "Constituency 33", + "x": 1, + "y": 1, + "average_household_income_change": -927.783221433379, + "relative_household_income_change": 0.6556433745742408, + "population": 56063.824089337824 + }, + { + "constituency_code": "E14000000034", + "constituency_name": "Constituency 34", + "x": 2, + "y": 1, + "average_household_income_change": 768.3322762327842, + "relative_household_income_change": -0.0, + "population": 146677.93429020338 + }, + { + "constituency_code": "E14000000035", + "constituency_name": "Constituency 35", + "x": 3, + "y": 1, + "average_household_income_change": -1729.540058696729, + "relative_household_income_change": 0.7274163682705892, + "population": 141793.29131328876 + }, + { + "constituency_code": "E14000000036", + "constituency_name": "Constituency 36", + "x": null, + "y": null, + "average_household_income_change": -846.208132023205, + "relative_household_income_change": 0.8483280186604456, + "population": 67143.97059156044 + }, + { + "constituency_code": "E14000000037", + "constituency_name": "Constituency 37", + "x": 5, + "y": 1, + "average_household_income_change": -1936.6401638583116, + "relative_household_income_change": -0.0, + "population": 61672.55464881558 + }, + { + "constituency_code": "E14000000038", + "constituency_name": "Constituency 38", + "x": 6, + "y": 1, + "average_household_income_change": -628.8145345899005, + "relative_household_income_change": 1e-12, + "population": 132985.11142986448 + }, + { + "constituency_code": "E14000000039", + "constituency_name": "Constituency 39", + "x": 7, + "y": 1, + "average_household_income_change": -1777.3930705516138, + "relative_household_income_change": 0.5276137376872608, + "population": 127787.70788799871 + }, + { + "constituency_code": "E14000000040", + "constituency_name": "Constituency 40", + "x": 8, + "y": 1, + "average_household_income_change": -939.8753120050504, + "relative_household_income_change": 1e-12, + "population": 102035.60610842177 + }, + { + "constituency_code": "E14000000041", + "constituency_name": "Constituency 41", + "x": 9, + "y": 1, + "average_household_income_change": 1996.581447976601, + "relative_household_income_change": -3.5e-9, + "population": 62516.54815850142 + }, + { + "constituency_code": "E14000000042", + "constituency_name": "Constituency 42", + "x": 10, + "y": 1, + "average_household_income_change": -1900.8595839448872, + "relative_household_income_change": 0.5897475984561965, + "population": 68761.98222160083 + }, + { + "constituency_code": "E14000000043", + "constituency_name": "Constituency 43", + "x": null, + "y": null, + "average_household_income_change": -653.4834431595642, + "relative_household_income_change": -0.0, + "population": 51348.43227638599 + }, + { + "constituency_code": "E14000000044", + "constituency_name": "Constituency 44", + "x": 12, + "y": 1, + "average_household_income_change": -1508.6955230422716, + "relative_household_income_change": -3.5e-9, + "population": 120576.53453498721 + }, + { + "constituency_code": "E14000000045", + "constituency_name": "Constituency 45", + "x": 13, + "y": 1, + "average_household_income_change": -1361.5842774754205, + "relative_household_income_change": -0.0, + "population": 57742.78406589189 + }, + { + "constituency_code": "E14000000046", + "constituency_name": "Constituency 46", + "x": 14, + "y": 1, + "average_household_income_change": -353.29379038553776, + "relative_household_income_change": -3.5e-9, + "population": 126551.15106107993 + }, + { + "constituency_code": "E14000000047", + "constituency_name": "Constituency 47", + "x": 15, + "y": 1, + "average_household_income_change": -1672.7900598157826, + "relative_household_income_change": -3.5e-9, + "population": 144378.29471218237 + }, + { + "constituency_code": "E14000000048", + "constituency_name": "Constituency 48", + "x": 16, + "y": 1, + "average_household_income_change": 758.8777810416283, + "relative_household_income_change": -0.0, + "population": 115096.92998669462 + }, + { + "constituency_code": "E14000000049", + "constituency_name": "Constituency 49", + "x": 17, + "y": 1, + "average_household_income_change": -1559.0227122087886, + "relative_household_income_change": 1e-12, + "population": 106776.20642347747 + }, + { + "constituency_code": "E14000000050", + "constituency_name": "Constituency 50", + "x": null, + "y": null, + "average_household_income_change": 789.7011489873229, + "relative_household_income_change": 0.0, + "population": 127076.9781852224 + }, + { + "constituency_code": "E14000000051", + "constituency_name": "Constituency 51", + "x": 19, + "y": 1, + "average_household_income_change": 158.31787373201277, + "relative_household_income_change": -3.5e-9, + "population": 77473.57871872757 + }, + { + "constituency_code": "E14000000052", + "constituency_name": "Constituency 52", + "x": 20, + "y": 1, + "average_household_income_change": 545.7397560998354, + "relative_household_income_change": 1e-12, + "population": 123149.70477872998 + }, + { + "constituency_code": "E14000000053", + "constituency_name": "Constituency 53", + "x": 21, + "y": 1, + "average_household_income_change": -1989.6254923025228, + "relative_household_income_change": -3.5e-9, + "population": 138656.01900310494 + }, + { + "constituency_code": "E14000000054", + "constituency_name": "Constituency 54", + "x": 22, + "y": 1, + "average_household_income_change": -1309.1057736433424, + "relative_household_income_change": -3.5e-9, + "population": 105353.90714704938 + }, + { + "constituency_code": "E14000000055", + "constituency_name": "Constituency 55", + "x": 23, + "y": 1, + "average_household_income_change": -910.2455237869887, + "relative_household_income_change": 1e-12, + "population": 92014.34882548383 + }, + { + "constituency_code": "E14000000056", + "constituency_name": "Constituency 56", + "x": 24, + "y": 1, + "average_household_income_change": -1893.3912344738899, + "relative_household_income_change": 1e-12, + "population": 62745.988954386 + }, + { + "constituency_code": "E14000000057", + "constituency_name": "Constituency 57", + "x": null, + "y": null, + "average_household_income_change": -233.6121744507622, + "relative_household_income_change": 0.0, + "population": 80531.25807633463 + }, + { + "constituency_code": "E14000000058", + "constituency_name": "Constituency 58", + "x": 26, + "y": 1, + "average_household_income_change": 1723.235202484926, + "relative_household_income_change": 0.0, + "population": 62353.128351459265 + }, + { + "constituency_code": "E14000000059", + "constituency_name": "Constituency 59", + "x": 27, + "y": 1, + "average_household_income_change": -989.7236409560759, + "relative_household_income_change": 0.0, + "population": 82844.38317215677 + }, + { + "constituency_code": "E14000000060", + "constituency_name": "Constituency 60", + "x": 28, + "y": 1, + "average_household_income_change": 1854.5416655237204, + "relative_household_income_change": 0.9442223354304049, + "population": 118156.82723049479 + }, + { + "constituency_code": "E14000000061", + "constituency_name": "Constituency 61", + "x": 29, + "y": 1, + "average_household_income_change": -1245.781306574102, + "relative_household_income_change": 0.10992560517344074, + "population": 121532.76585539519 + }, + { + "constituency_code": "E14000000062", + "constituency_name": "Constituency 62", + "x": 30, + "y": 1, + "average_household_income_change": 447.36217162511946, + "relative_household_income_change": -0.0, + "population": 90921.07222144597 + }, + { + "constituency_code": "E14000000063", + "constituency_name": "Constituency 63", + "x": 0, + "y": 2, + "average_household_income_change": 1977.7483196114183, + "relative_household_income_change": -0.0, + "population": 145071.50512103183 + }, + { + "constituency_code": "E14000000064", + "constituency_name": "Constituency 64", + "x": null, + "y": null, + "average_household_income_change": 836.8416626768695, + "relative_household_income_change": 0.3827069272720205, + "population": 80491.97355110932 + }, + { + "constituency_code": "E14000000065", + "constituency_name": "Constituency 65", + "x": 2, + "y": 2, + "average_household_income_change": -646.4321678071556, + "relative_household_income_change": 0.6461356256019504, + "population": 82414.78499033627 + }, + { + "constituency_code": "E14000000066", + "constituency_name": "Constituency 66", + "x": 3, + "y": 2, + "average_household_income_change": -269.67895991541104, + "relative_household_income_change": 0.0, + "population": 107324.65179222659 + }, + { + "constituency_code": "E14000000067", + "constituency_name": "Constituency 67", + "x": 4, + "y": 2, + "average_household_income_change": -766.5123752502452, + "relative_household_income_change": 0.5355509950976918, + "population": 121437.09706568257 + }, + { + "constituency_code": "E14000000068", + "constituency_name": "Constituency 68", + "x": 5, + "y": 2, + "average_household_income_change": 1137.686580720843, + "relative_household_income_change": 1e-12, + "population": 105524.95460769659 + }, + { + "constituency_code": "E14000000069", + "constituency_name": "Constituency 69", + "x": 6, + "y": 2, + "average_household_income_change": -1706.7509137766676, + "relative_household_income_change": 1e-12, + "population": 89192.43391252482 + }, + { + "constituency_code": "E14000000070", + "constituency_name": "Constituency 70", + "x": 7, + "y": 2, + "average_household_income_change": 290.0010881146389, + "relative_household_income_change": 0.0, + "population": 105626.728450015 + }, + { + "constituency_code": "E14000000071", + "constituency_name": "Constituency 71", + "x": null, + "y": null, + "average_household_income_change": -1040.696959610678, + "relative_household_income_change": -0.0, + "population": 113268.62178290357 + }, + { + "constituency_code": "E14000000072", + "constituency_name": "Constituency 72", + "x": 9, + "y": 2, + "average_household_income_change": -635.9903698714575, + "relative_household_income_change": 0.0, + "population": 56501.415493660155 + }, + { + "constituency_code": "E14000000073", + "constituency_name": "Constituency 73", + "x": 10, + "y": 2, + "average_household_income_change": -1854.3994611686637, + "relative_household_income_change": -3.5e-9, + "population": 102254.43848842036 + }, + { + "constituency_code": "E14000000074", + "constituency_name": "Constituency 74", + "x": 11, + "y": 2, + "average_household_income_change": -90.81831049588996, + "relative_household_income_change": -0.0, + "population": 80954.3654644868 + }, + { + "constituency_code": "E14000000075", + "constituency_name": "Constituency 75", + "x": 12, + "y": 2, + "average_household_income_change": -427.27767032936634, + "relative_household_income_change": -0.0, + "population": 108071.93041754297 + }, + { + "constituency_code": "E14000000076", + "constituency_name": "Constituency 76", + "x": 13, + "y": 2, + "average_household_income_change": -127.30625312048672, + "relative_household_income_change": 1e-12, + "population": 117467.31956453231 + }, + { + "constituency_code": "E14000000077", + "constituency_name": "Constituency 77", + "x": 14, + "y": 2, + "average_household_income_change": 1230.791969781145, + "relative_household_income_change": -3.5e-9, + "population": 148070.92092286033 + }, + { + "constituency_code": "E14000000078", + "constituency_name": "Constituency 78", + "x": null, + "y": null, + "average_household_income_change": 961.4866644663272, + "relative_household_income_change": 0.0, + "population": 102372.13679715489 + }, + { + "constituency_code": "E14000000079", + "constituency_name": "Constituency 79", + "x": 16, + "y": 2, + "average_household_income_change": 558.5324418439463, + "relative_household_income_change": 0.0, + "population": 80215.38631281923 + }, + { + "constituency_code": "E14000000080", + "constituency_name": "Constituency 80", + "x": 17, + "y": 2, + "average_household_income_change": 1454.8518394753298, + "relative_household_income_change": -3.5e-9, + "population": 96129.88324153051 + }, + { + "constituency_code": "E14000000081", + "constituency_name": "Constituency 81", + "x": 18, + "y": 2, + "average_household_income_change": -1995.8700108929918, + "relative_household_income_change": -0.0, + "population": 117530.89833643334 + }, + { + "constituency_code": "E14000000082", + "constituency_name": "Constituency 82", + "x": 19, + "y": 2, + "average_household_income_change": -658.2352550207946, + "relative_household_income_change": 0.0, + "population": 91829.77214309663 + }, + { + "constituency_code": "E14000000083", + "constituency_name": "Constituency 83", + "x": 20, + "y": 2, + "average_household_income_change": 188.26514420342198, + "relative_household_income_change": 1e-12, + "population": 66881.29106756164 + }, + { + "constituency_code": "E14000000084", + "constituency_name": "Constituency 84", + "x": 21, + "y": 2, + "average_household_income_change": 345.8706592634417, + "relative_household_income_change": 1e-12, + "population": 56080.595832210536 + }, + { + "constituency_code": "E14000000085", + "constituency_name": "Constituency 85", + "x": null, + "y": null, + "average_household_income_change": -1673.2370069478045, + "relative_household_income_change": -3.5e-9, + "population": 132596.5586019211 + }, + { + "constituency_code": "E14000000086", + "constituency_name": "Constituency 86", + "x": 23, + "y": 2, + "average_household_income_change": -127.34504781316191, + "relative_household_income_change": -0.0, + "population": 108141.83862604474 + }, + { + "constituency_code": "E14000000087", + "constituency_name": "Constituency 87", + "x": 24, + "y": 2, + "average_household_income_change": -1334.0686487199855, + "relative_household_income_change": -0.0, + "population": 64208.1603564976 + }, + { + "constituency_code": "E14000000088", + "constituency_name": "Constituency 88", + "x": 25, + "y": 2, + "average_household_income_change": 902.6394274385958, + "relative_household_income_change": 0.0, + "population": 96826.50596375517 + }, + { + "constituency_code": "E14000000089", + "constituency_name": "Constituency 89", + "x": 26, + "y": 2, + "average_household_income_change": -599.3606996628422, + "relative_household_income_change": 1e-12, + "population": 73495.08876254177 + }, + { + "constituency_code": "E14000000090", + "constituency_name": "Constituency 90", + "x": 27, + "y": 2, + "average_household_income_change": 1736.004403868296, + "relative_household_income_change": 0.0, + "population": 72671.92918410263 + }, + { + "constituency_code": "E14000000091", + "constituency_name": "Constituency 91", + "x": 28, + "y": 2, + "average_household_income_change": -183.77106589635696, + "relative_household_income_change": 0.43806674434402326, + "population": 114528.8279064073 + }, + { + "constituency_code": "E14000000092", + "constituency_name": "Constituency 92", + "x": null, + "y": null, + "average_household_income_change": 748.6525324379664, + "relative_household_income_change": -0.0, + "population": 115796.32346101322 + }, + { + "constituency_code": "E14000000093", + "constituency_name": "Constituency 93", + "x": 30, + "y": 2, + "average_household_income_change": 879.2492998869496, + "relative_household_income_change": 0.9812499439637178, + "population": 72504.37458082387 + }, + { + "constituency_code": "E14000000094", + "constituency_name": "Constituency 94", + "x": 0, + "y": 3, + "average_household_income_change": 88.85783739759168, + "relative_household_income_change": -0.0, + "population": 98878.56897288407 + }, + { + "constituency_code": "E14000000095", + "constituency_name": "Constituency 95", + "x": 1, + "y": 3, + "average_household_income_change": 1647.1065576113149, + "relative_household_income_change": 1e-12, + "population": 143872.09389896045 + }, + { + "constituency_code": "E14000000096", + "constituency_name": "Constituency 96", + "x": 2, + "y": 3, + "average_household_income_change": 1000.4981954112053, + "relative_household_income_change": 0.2974922761899098, + "population": 95587.75122445801 + }, + { + "constituency_code": "E14000000097", + "constituency_name": "Constituency 97", + "x": 3, + "y": 3, + "average_household_income_change": 662.0195760611831, + "relative_household_income_change": 0.24624440427242644, + "population": 74596.90118944892 + }, + { + "constituency_code": "E14000000098", + "constituency_name": "Constituency 98", + "x": 4, + "y": 3, + "average_household_income_change": -969.5701848891072, + "relative_household_income_change": -3.5e-9, + "population": 119650.20784516653 + }, + { + "constituency_code": "E14000000099", + "constituency_name": "Constituency 99", + "x": null, + "y": null, + "average_household_income_change": 1640.698557999197, + "relative_household_income_change": 1e-12, + "population": 65758.83878464595 + }, + { + "constituency_code": "E14000000100", + "constituency_name": "Constituency 100", + "x": 6, + "y": 3, + "average_household_income_change": -1807.137308302433, + "relative_household_income_change": 0.20830978548035517, + "population": 130632.53138420294 + }, + { + "constituency_code": "E14000000101", + "constituency_name": "Constituency 101", + "x": 7, + "y": 3, + "average_household_income_change": 249.5946512422913, + "relative_household_income_change": 0.0, + "population": 117617.37454965772 + }, + { + "constituency_code": "E14000000102", + "constituency_name": "Constituency 102", + "x": 8, + "y": 3, + "average_household_income_change": 1704.7622150184222, + "relative_household_income_change": 0.040006378333672155, + "population": 91743.07750685193 + }, + { + "constituency_code": "E14000000103", + "constituency_name": "Constituency 103", + "x": 9, + "y": 3, + "average_household_income_change": 1323.0082082453052, + "relative_household_income_change": -0.0, + "population": 82625.82863494732 + }, + { + "constituency_code": "E14000000104", + "constituency_name": "Constituency 104", + "x": 10, + "y": 3, + "average_household_income_change": -548.9345688529448, + "relative_household_income_change": 0.0, + "population": 106252.55590308961 + }, + { + "constituency_code": "E14000000105", + "constituency_name": "Constituency 105", + "x": 11, + "y": 3, + "average_household_income_change": 1490.3340145481927, + "relative_household_income_change": -0.0, + "population": 52780.212642555765 + }, + { + "constituency_code": "E14000000106", + "constituency_name": "Constituency 106", + "x": null, + "y": null, + "average_household_income_change": -110.74050107692868, + "relative_household_income_change": -0.0, + "population": 132857.81166528582 + }, + { + "constituency_code": "E14000000107", + "constituency_name": "Constituency 107", + "x": 13, + "y": 3, + "average_household_income_change": 535.7247779833637, + "relative_household_income_change": 1e-12, + "population": 70233.9136272132 + }, + { + "constituency_code": "E14000000108", + "constituency_name": "Constituency 108", + "x": 14, + "y": 3, + "average_household_income_change": -1562.196706775103, + "relative_household_income_change": 1e-12, + "population": 57613.888020112136 + }, + { + "constituency_code": "E14000000109", + "constituency_name": "Constituency 109", + "x": 15, + "y": 3, + "average_household_income_change": -807.5046810198771, + "relative_household_income_change": 0.0, + "population": 59873.63482115029 + }, + { + "constituency_code": "E14000000110", + "constituency_name": "Constituency 110", + "x": 16, + "y": 3, + "average_household_income_change": 831.9248746300553, + "relative_household_income_change": 0.7884886691726886, + "population": 118598.10011798801 + }, + { + "constituency_code": "E14000000111", + "constituency_name": "Constituency 111", + "x": 17, + "y": 3, + "average_household_income_change": -1626.7832810843204, + "relative_household_income_change": 1e-12, + "population": 119305.13175442934 + }, + { + "constituency_code": "E14000000112", + "constituency_name": "Constituency 112", + "x": 18, + "y": 3, + "average_household_income_change": 1429.6328025898952, + "relative_household_income_change": 1e-12, + "population": 102121.6917442946 + }, + { + "constituency_code": "E14000000113", + "constituency_name": "Constituency 113", + "x": null, + "y": null, + "average_household_income_change": -1344.590914850559, + "relative_household_income_change": 0.0, + "population": 62858.80426314438 + }, + { + "constituency_code": "E14000000114", + "constituency_name": "Constituency 114", + "x": 20, + "y": 3, + "average_household_income_change": -1146.3259480815013, + "relative_household_income_change": 0.5837592442382298, + "population": 122906.79170362945 + }, + { + "constituency_code": "E14000000115", + "constituency_name": "Constituency 115", + "x": 21, + "y": 3, + "average_household_income_change": 898.8663515055783, + "relative_household_income_change": 0.0, + "population": 99534.98962730182 + }, + { + "constituency_code": "E14000000116", + "constituency_name": "Constituency 116", + "x": 22, + "y": 3, + "average_household_income_change": 1719.4014329329482, + "relative_household_income_change": -3.5e-9, + "population": 69591.52802599104 + }, + { + "constituency_code": "E14000000117", + "constituency_name": "Constituency 117", + "x": 23, + "y": 3, + "average_household_income_change": 562.4608552702921, + "relative_household_income_change": 0.0, + "population": 55842.24291191352 + }, + { + "constituency_code": "E14000000118", + "constituency_name": "Constituency 118", + "x": 24, + "y": 3, + "average_household_income_change": -1800.665380166868, + "relative_household_income_change": 0.0, + "population": 58657.962368604676 + }, + { + "constituency_code": "E14000000119", + "constituency_name": "Constituency 119", + "x": 25, + "y": 3, + "average_household_income_change": -1818.6883148220843, + "relative_household_income_change": -0.0, + "population": 137813.88099731703 + }, + { + "constituency_code": "E14000000120", + "constituency_name": "Constituency 120", + "x": null, + "y": null, + "average_household_income_change": 455.83119363301086, + "relative_household_income_change": 1e-12, + "population": 57970.009217312014 + }, + { + "constituency_code": "E14000000121", + "constituency_name": "Constituency 121", + "x": 27, + "y": 3, + "average_household_income_change": 1782.7850710537834, + "relative_household_income_change": 0.0, + "population": 143164.58131962447 + }, + { + "constituency_code": "E14000000122", + "constituency_name": "Constituency 122", + "x": 28, + "y": 3, + "average_household_income_change": -1321.9039327565874, + "relative_household_income_change": 0.541962411948292, + "population": 50457.04856292268 + }, + { + "constituency_code": "E14000000123", + "constituency_name": "Constituency 123", + "x": 29, + "y": 3, + "average_household_income_change": -1609.0297193715548, + "relative_household_income_change": 1e-12, + "population": 135630.50062274272 + }, + { + "constituency_code": "E14000000124", + "constituency_name": "Constituency 124", + "x": 30, + "y": 3, + "average_household_income_change": 1494.6489038444706, + "relative_household_income_change": -0.0, + "population": 86499.87244664696 + }, + { + "constituency_code": "E14000000125", + "constituency_name": "Constituency 125", + "x": 0, + "y": 4, + "average_household_income_change": -203.1025816503482, + "relative_household_income_change": -3.5e-9, + "population": 131450.3581071367 + }, + { + "constituency_code": "E14000000126", + "constituency_name": "Constituency 126", + "x": 1, + "y": 4, + "average_household_income_change": -1736.00876747027, + "relative_household_income_change": 1e-12, + "population": 77714.90708517047 + }, + { + "constituency_code": "E14000000127", + "constituency_name": "Constituency 127", + "x": null, + "y": null, + "average_household_income_change": 732.7271206158956, + "relative_household_income_change": 0.0, + "population": 62311.51182123555 + }, + { + "constituency_code": "E14000000128", + "constituency_name": "Constituency 128", + "x": 3, + "y": 4, + "average_household_income_change": 759.0741072953095, + "relative_household_income_change": 1e-12, + "population": 148750.0421308927 + }, + { + "constituency_code": "E14000000129", + "constituency_name": "Constituency 129", + "x": 4, + "y": 4, + "average_household_income_change": -112.66921174672939, + "relative_household_income_change": -0.0, + "population": 143434.03244226988 + }, + { + "constituency_code": "E14000000130", + "constituency_name": "Constituency 130", + "x": 5, + "y": 4, + "average_household_income_change": 691.029924028856, + "relative_household_income_change": 1e-12, + "population": 99922.79421022549 + }, + { + "constituency_code": "E14000000131", + "constituency_name": "Constituency 131", + "x": 6, + "y": 4, + "average_household_income_change": 1441.9814618812738, + "relative_household_income_change": -3.5e-9, + "population": 131523.50923887704 + }, + { + "constituency_code": "E14000000132", + "constituency_name": "Constituency 132", + "x": 7, + "y": 4, + "average_household_income_change": -1430.5752926829505, + "relative_household_income_change": -3.5e-9, + "population": 137924.9037228661 + }, + { + "constituency_code": "E14000000133", + "constituency_name": "Constituency 133", + "x": 8, + "y": 4, + "average_household_income_change": -534.625803263033, + "relative_household_income_change": 0.7529910574305773, + "population": 148437.97322438212 + }, + { + "constituency_code": "E14000000134", + "constituency_name": "Constituency 134", + "x": null, + "y": null, + "average_household_income_change": 61.61400773596615, + "relative_household_income_change": -0.0, + "population": 111496.92620513316 + }, + { + "constituency_code": "E14000000135", + "constituency_name": "Constituency 135", + "x": 10, + "y": 4, + "average_household_income_change": 1905.6857330111927, + "relative_household_income_change": -3.5e-9, + "population": 138584.5575915625 + }, + { + "constituency_code": "E14000000136", + "constituency_name": "Constituency 136", + "x": 11, + "y": 4, + "average_household_income_change": -1144.1054423786206, + "relative_household_income_change": 0.2928856424926053, + "population": 53197.31312326364 + }, + { + "constituency_code": "E14000000137", + "constituency_name": "Constituency 137", + "x": 12, + "y": 4, + "average_household_income_change": -1265.1713617127114, + "relative_household_income_change": -3.5e-9, + "population": 86572.81347329413 + }, + { + "constituency_code": "E14000000138", + "constituency_name": "Constituency 138", + "x": 13, + "y": 4, + "average_household_income_change": -519.7588256792973, + "relative_household_income_change": 1e-12, + "population": 71191.46671555749 + }, + { + "constituency_code": "E14000000139", + "constituency_name": "Constituency 139", + "x": 14, + "y": 4, + "average_household_income_change": 162.22254008254345, + "relative_household_income_change": 0.8858936695673891, + "population": 96442.4130437495 + }, + { + "constituency_code": "E14000000140", + "constituency_name": "Constituency 140", + "x": 15, + "y": 4, + "average_household_income_change": -242.46320601773473, + "relative_household_income_change": 0.0, + "population": 80110.23234374468 + }, + { + "constituency_code": "E14000000141", + "constituency_name": "Constituency 141", + "x": null, + "y": null, + "average_household_income_change": 816.202709092623, + "relative_household_income_change": 1e-12, + "population": 73590.60951765552 + }, + { + "constituency_code": "E14000000142", + "constituency_name": "Constituency 142", + "x": 17, + "y": 4, + "average_household_income_change": -833.1977474989549, + "relative_household_income_change": 0.0, + "population": 61505.33961873027 + }, + { + "constituency_code": "E14000000143", + "constituency_name": "Constituency 143", + "x": 18, + "y": 4, + "average_household_income_change": 1160.9421061865896, + "relative_household_income_change": 0.0, + "population": 115466.82296615103 + }, + { + "constituency_code": "E14000000144", + "constituency_name": "Constituency 144", + "x": 19, + "y": 4, + "average_household_income_change": -907.0148122290055, + "relative_household_income_change": -0.0, + "population": 146736.6126186456 + }, + { + "constituency_code": "E14000000145", + "constituency_name": "Constituency 145", + "x": 20, + "y": 4, + "average_household_income_change": 35.53462916544747, + "relative_household_income_change": 0.0, + "population": 64441.56694242156 + }, + { + "constituency_code": "E14000000146", + "constituency_name": "Constituency 146", + "x": 21, + "y": 4, + "average_household_income_change": -124.46281500682232, + "relative_household_income_change": -0.0, + "population": 120349.10959497117 + }, + { + "constituency_code": "E14000000147", + "constituency_name": "Constituency 147", + "x": 22, + "y": 4, + "average_household_income_change": -862.1979283911992, + "relative_household_income_change": 1e-12, + "population": 75978.88726544433 + }, + { + "constituency_code": "E14000000148", + "constituency_name": "Constituency 148", + "x": null, + "y": null, + "average_household_income_change": 1140.4649528680948, + "relative_household_income_change": 0.0, + "population": 54047.383882475355 + }, + { + "constituency_code": "E14000000149", + "constituency_name": "Constituency 149", + "x": 24, + "y": 4, + "average_household_income_change": 678.6107114798727, + "relative_household_income_change": -3.5e-9, + "population": 117413.54107700937 + }, + { + "constituency_code": "E14000000150", + "constituency_name": "Constituency 150", + "x": 25, + "y": 4, + "average_household_income_change": -968.559432686301, + "relative_household_income_change": 0.37852725111076435, + "population": 115903.15108720168 + }, + { + "constituency_code": "E14000000151", + "constituency_name": "Constituency 151", + "x": 26, + "y": 4, + "average_household_income_change": -1129.3003769282795, + "relative_household_income_change": -3.5e-9, + "population": 144612.66162939422 + }, + { + "constituency_code": "E14000000152", + "constituency_name": "Constituency 152", + "x": 27, + "y": 4, + "average_household_income_change": 1269.0375684969467, + "relative_household_income_change": -3.5e-9, + "population": 103290.76445801418 + }, + { + "constituency_code": "E14000000153", + "constituency_name": "Constituency 153", + "x": 28, + "y": 4, + "average_household_income_change": -484.59589825511887, + "relative_household_income_change": 0.0, + "population": 78314.43303172 + }, + { + "constituency_code": "E14000000154", + "constituency_name": "Constituency 154", + "x": 29, + "y": 4, + "average_household_income_change": -1495.7935523863468, + "relative_household_income_change": -0.0, + "population": 64071.97759800385 + }, + { + "constituency_code": "E14000000155", + "constituency_name": "Constituency 155", + "x": null, + "y": null, + "average_household_income_change": -389.87763296794054, + "relative_household_income_change": 0.0, + "population": 54102.4304805369 + }, + { + "constituency_code": "E14000000156", + "constituency_name": "Constituency 156", + "x": 0, + "y": 5, + "average_household_income_change": 1643.525970306858, + "relative_household_income_change": 0.0, + "population": 114985.93268203211 + }, + { + "constituency_code": "E14000000157", + "constituency_name": "Constituency 157", + "x": 1, + "y": 5, + "average_household_income_change": 504.13911589940864, + "relative_household_income_change": 1e-12, + "population": 96530.2084560394 + }, + { + "constituency_code": "E14000000158", + "constituency_name": "Constituency 158", + "x": 2, + "y": 5, + "average_household_income_change": 1753.9239358191967, + "relative_household_income_change": 0.17275558712005012, + "population": 63841.377140504985 + }, + { + "constituency_code": "E14000000159", + "constituency_name": "Constituency 159", + "x": 3, + "y": 5, + "average_household_income_change": -1655.610381708632, + "relative_household_income_change": 1e-12, + "population": 59182.04001348608 + }, + { + "constituency_code": "E14000000160", + "constituency_name": "Constituency 160", + "x": 4, + "y": 5, + "average_household_income_change": 1493.1892934146344, + "relative_household_income_change": 0.05167241091737229, + "population": 134883.8787827266 + }, + { + "constituency_code": "E14000000161", + "constituency_name": "Constituency 161", + "x": 5, + "y": 5, + "average_household_income_change": -1009.785320605499, + "relative_household_income_change": -0.0, + "population": 105313.0376348572 + }, + { + "constituency_code": "E14000000162", + "constituency_name": "Constituency 162", + "x": null, + "y": null, + "average_household_income_change": 1985.790511051385, + "relative_household_income_change": 0.0, + "population": 59046.08239782301 + }, + { + "constituency_code": "E14000000163", + "constituency_name": "Constituency 163", + "x": 7, + "y": 5, + "average_household_income_change": -624.0407357817598, + "relative_household_income_change": -3.5e-9, + "population": 133938.68480033753 + }, + { + "constituency_code": "E14000000164", + "constituency_name": "Constituency 164", + "x": 8, + "y": 5, + "average_household_income_change": -1219.380899954297, + "relative_household_income_change": -3.5e-9, + "population": 140688.8228403655 + }, + { + "constituency_code": "E14000000165", + "constituency_name": "Constituency 165", + "x": 9, + "y": 5, + "average_household_income_change": 1082.0360022946556, + "relative_household_income_change": -0.0, + "population": 119206.25377648813 + }, + { + "constituency_code": "E14000000166", + "constituency_name": "Constituency 166", + "x": 10, + "y": 5, + "average_household_income_change": 1270.7265069357318, + "relative_household_income_change": 0.0, + "population": 60177.53130640996 + }, + { + "constituency_code": "E14000000167", + "constituency_name": "Constituency 167", + "x": 11, + "y": 5, + "average_household_income_change": -173.76753307330173, + "relative_household_income_change": -3.5e-9, + "population": 92609.98355546329 + }, + { + "constituency_code": "E14000000168", + "constituency_name": "Constituency 168", + "x": 12, + "y": 5, + "average_household_income_change": 84.60524462623465, + "relative_household_income_change": 0.0, + "population": 109246.06859637471 + }, + { + "constituency_code": "E14000000169", + "constituency_name": "Constituency 169", + "x": null, + "y": null, + "average_household_income_change": 1226.4575806689, + "relative_household_income_change": -3.5e-9, + "population": 85779.77623277435 + }, + { + "constituency_code": "E14000000170", + "constituency_name": "Constituency 170", + "x": 14, + "y": 5, + "average_household_income_change": 1897.8839697841004, + "relative_household_income_change": 0.3673733728077122, + "population": 134526.85600579536 + }, + { + "constituency_code": "E14000000171", + "constituency_name": "Constituency 171", + "x": 15, + "y": 5, + "average_household_income_change": -96.71162396045486, + "relative_household_income_change": -0.0, + "population": 135267.77830307873 + }, + { + "constituency_code": "E14000000172", + "constituency_name": "Constituency 172", + "x": 16, + "y": 5, + "average_household_income_change": 1734.8446483630125, + "relative_household_income_change": 0.7508561605283052, + "population": 121288.32325809612 + }, + { + "constituency_code": "E14000000173", + "constituency_name": "Constituency 173", + "x": 17, + "y": 5, + "average_household_income_change": 1116.8285362297524, + "relative_household_income_change": 0.0, + "population": 143666.5638784498 + }, + { + "constituency_code": "E14000000174", + "constituency_name": "Constituency 174", + "x": 18, + "y": 5, + "average_household_income_change": 1731.2154456492808, + "relative_household_income_change": -3.5e-9, + "population": 124824.5757648796 + }, + { + "constituency_code": "E14000000175", + "constituency_name": "Constituency 175", + "x": 19, + "y": 5, + "average_household_income_change": 1948.654638465101, + "relative_household_income_change": 0.4978536471952415, + "population": 131100.4905236806 + }, + { + "constituency_code": "E14000000176", + "constituency_name": "Constituency 176", + "x": null, + "y": null, + "average_household_income_change": -511.27124006434775, + "relative_household_income_change": 0.13061907743062717, + "population": 116551.31582614966 + }, + { + "constituency_code": "E14000000177", + "constituency_name": "Constituency 177", + "x": 21, + "y": 5, + "average_household_income_change": -1935.029384051584, + "relative_household_income_change": 1e-12, + "population": 137063.16851170457 + }, + { + "constituency_code": "E14000000178", + "constituency_name": "Constituency 178", + "x": 22, + "y": 5, + "average_household_income_change": -911.186276780501, + "relative_household_income_change": -0.0, + "population": 61965.39400983384 + }, + { + "constituency_code": "E14000000179", + "constituency_name": "Constituency 179", + "x": 23, + "y": 5, + "average_household_income_change": 1733.1720235382163, + "relative_household_income_change": 0.0, + "population": 142246.5510827012 + }, + { + "constituency_code": "E14000000180", + "constituency_name": "Constituency 180", + "x": 24, + "y": 5, + "average_household_income_change": 1661.238755153409, + "relative_household_income_change": 0.0, + "population": 90367.91791250638 + }, + { + "constituency_code": "E14000000181", + "constituency_name": "Constituency 181", + "x": 25, + "y": 5, + "average_household_income_change": 345.2949680294841, + "relative_household_income_change": -3.5e-9, + "population": 149980.7029767488 + }, + { + "constituency_code": "E14000000182", + "constituency_name": "Constituency 182", + "x": 26, + "y": 5, + "average_household_income_change": -988.0666839819572, + "relative_household_income_change": -0.0, + "population": 128097.92539036271 + }, + { + "constituency_code": "E14000000183", + "constituency_name": "Constituency 183", + "x": null, + "y": null, + "average_household_income_change": 902.1430784366627, + "relative_household_income_change": 0.09220506661790073, + "population": 85359.46880565214 + }, + { + "constituency_code": "E14000000184", + "constituency_name": "Constituency 184", + "x": 28, + "y": 5, + "average_household_income_change": -957.5128615802955, + "relative_household_income_change": 0.0, + "population": 57657.65475740139 + }, + { + "constituency_code": "E14000000185", + "constituency_name": "Constituency 185", + "x": 29, + "y": 5, + "average_household_income_change": -753.5040279733156, + "relative_household_income_change": 1e-12, + "population": 98478.87085938707 + }, + { + "constituency_code": "E14000000186", + "constituency_name": "Constituency 186", + "x": 30, + "y": 5, + "average_household_income_change": 1655.0348378318804, + "relative_household_income_change": -0.0, + "population": 140589.08503782647 + }, + { + "constituency_code": "E14000000187", + "constituency_name": "Constituency 187", + "x": 0, + "y": 6, + "average_household_income_change": -457.6924571642285, + "relative_household_income_change": -3.5e-9, + "population": 60506.32404568623 + }, + { + "constituency_code": "E14000000188", + "constituency_name": "Constituency 188", + "x": 1, + "y": 6, + "average_household_income_change": 955.5435083380046, + "relative_household_income_change": 0.0, + "population": 148637.46753157367 + }, + { + "constituency_code": "E14000000189", + "constituency_name": "Constituency 189", + "x": 2, + "y": 6, + "average_household_income_change": -824.5635557117255, + "relative_household_income_change": 0.0, + "population": 99782.64274656228 + }, + { + "constituency_code": "E14000000190", + "constituency_name": "Constituency 190", + "x": null, + "y": null, + "average_household_income_change": -534.8346178311823, + "relative_household_income_change": 1e-12, + "population": 55067.263103974285 + }, + { + "constituency_code": "E14000000191", + "constituency_name": "Constituency 191", + "x": 4, + "y": 6, + "average_household_income_change": 1124.6850271956769, + "relative_household_income_change": -0.0, + "population": 135639.43667060742 + }, + { + "constituency_code": "E14000000192", + "constituency_name": "Constituency 192", + "x": 5, + "y": 6, + "average_household_income_change": 1915.0183852603498, + "relative_household_income_change": -0.0, + "population": 72513.67422403721 + }, + { + "constituency_code": "E14000000193", + "constituency_name": "Constituency 193", + "x": 6, + "y": 6, + "average_household_income_change": 468.543806087232, + "relative_household_income_change": 1e-12, + "population": 51295.48380385167 + }, + { + "constituency_code": "E14000000194", + "constituency_name": "Constituency 194", + "x": 7, + "y": 6, + "average_household_income_change": -1970.722745123179, + "relative_household_income_change": -3.5e-9, + "population": 90662.16267914974 + }, + { + "constituency_code": "E14000000195", + "constituency_name": "Constituency 195", + "x": 8, + "y": 6, + "average_household_income_change": 1541.7517077241403, + "relative_household_income_change": 0.0, + "population": 51287.49142035601 + }, + { + "constituency_code": "E14000000196", + "constituency_name": "Constituency 196", + "x": 9, + "y": 6, + "average_household_income_change": -561.0043898564228, + "relative_household_income_change": -0.0, + "population": 148348.3321363182 + }, + { + "constituency_code": "E14000000197", + "constituency_name": "Constituency 197", + "x": null, + "y": null, + "average_household_income_change": 847.9949757046952, + "relative_household_income_change": 0.12100126791021937, + "population": 146252.67973033 + }, + { + "constituency_code": "E14000000198", + "constituency_name": "Constituency 198", + "x": 11, + "y": 6, + "average_household_income_change": -122.91164421650168, + "relative_household_income_change": 0.0, + "population": 108822.50841263078 + }, + { + "constituency_code": "E14000000199", + "constituency_name": "Constituency 199", + "x": 12, + "y": 6, + "average_household_income_change": 210.35075187056896, + "relative_household_income_change": 1e-12, + "population": 73060.15474275278 + }, + { + "constituency_code": "E14000000200", + "constituency_name": "Constituency 200", + "x": 13, + "y": 6, + "average_household_income_change": 1373.2772747493455, + "relative_household_income_change": -3.5e-9, + "population": 54562.39026607954 + }, + { + "constituency_code": "E14000000201", + "constituency_name": "Constituency 201", + "x": 14, + "y": 6, + "average_household_income_change": 1775.7579273582728, + "relative_household_income_change": -0.0, + "population": 68571.93485422268 + }, + { + "constituency_code": "E14000000202", + "constituency_name": "Constituency 202", + "x": 15, + "y": 6, + "average_household_income_change": 487.9521355800094, + "relative_household_income_change": 1e-12, + "population": 52440.31636393422 + }, + { + "constituency_code": "E14000000203", + "constituency_name": "Constituency 203", + "x": 16, + "y": 6, + "average_household_income_change": -1322.0472054070465, + "relative_household_income_change": 0.7971877766379147, + "population": 135070.588467467 + }, + { + "constituency_code": "E14000000204", + "constituency_name": "Constituency 204", + "x": null, + "y": null, + "average_household_income_change": 1509.8157970664406, + "relative_household_income_change": 1e-12, + "population": 144410.89387433697 + }, + { + "constituency_code": "E14000000205", + "constituency_name": "Constituency 205", + "x": 18, + "y": 6, + "average_household_income_change": 817.8577390299471, + "relative_household_income_change": -0.0, + "population": 110217.0617559504 + }, + { + "constituency_code": "E14000000206", + "constituency_name": "Constituency 206", + "x": 19, + "y": 6, + "average_household_income_change": -519.2624488298457, + "relative_household_income_change": 0.13511138675068546, + "population": 76651.5628612962 + }, + { + "constituency_code": "E14000000207", + "constituency_name": "Constituency 207", + "x": 20, + "y": 6, + "average_household_income_change": -1085.0536783564949, + "relative_household_income_change": -0.0, + "population": 60836.20787139632 + }, + { + "constituency_code": "E14000000208", + "constituency_name": "Constituency 208", + "x": 21, + "y": 6, + "average_household_income_change": 301.6568478061923, + "relative_household_income_change": 0.0, + "population": 87157.94506624367 + }, + { + "constituency_code": "E14000000209", + "constituency_name": "Constituency 209", + "x": 22, + "y": 6, + "average_household_income_change": -1673.3597854652503, + "relative_household_income_change": 0.4169266309393279, + "population": 68426.82588844819 + }, + { + "constituency_code": "E14000000210", + "constituency_name": "Constituency 210", + "x": 23, + "y": 6, + "average_household_income_change": -83.09691011490594, + "relative_household_income_change": -0.0, + "population": 126920.19674232941 + }, + { + "constituency_code": "E14000000211", + "constituency_name": "Constituency 211", + "x": null, + "y": null, + "average_household_income_change": 237.6712620893251, + "relative_household_income_change": 1e-12, + "population": 60236.095478765325 + }, + { + "constituency_code": "E14000000212", + "constituency_name": "Constituency 212", + "x": 25, + "y": 6, + "average_household_income_change": -1585.4887263147605, + "relative_household_income_change": -0.0, + "population": 116481.5354173281 + }, + { + "constituency_code": "E14000000213", + "constituency_name": "Constituency 213", + "x": 26, + "y": 6, + "average_household_income_change": 52.57033563292879, + "relative_household_income_change": 0.0, + "population": 79714.87039267518 + }, + { + "constituency_code": "E14000000214", + "constituency_name": "Constituency 214", + "x": 27, + "y": 6, + "average_household_income_change": 140.7077209407901, + "relative_household_income_change": 1e-12, + "population": 132320.27403479567 + }, + { + "constituency_code": "E14000000215", + "constituency_name": "Constituency 215", + "x": 28, + "y": 6, + "average_household_income_change": -1284.1135147208838, + "relative_household_income_change": -3.5e-9, + "population": 106469.78321771967 + }, + { + "constituency_code": "E14000000216", + "constituency_name": "Constituency 216", + "x": 29, + "y": 6, + "average_household_income_change": -1792.2895026530314, + "relative_household_income_change": -0.0, + "population": 99990.59878057335 + }, + { + "constituency_code": "E14000000217", + "constituency_name": "Constituency 217", + "x": 30, + "y": 6, + "average_household_income_change": -783.8278631034128, + "relative_household_income_change": 0.28888357094563333, + "population": 110733.83910680971 + }, + { + "constituency_code": "E14000000218", + "constituency_name": "Constituency 218", + "x": null, + "y": null, + "average_household_income_change": 642.037289695729, + "relative_household_income_change": -0.0, + "population": 90348.9414403198 + }, + { + "constituency_code": "E14000000219", + "constituency_name": "Constituency 219", + "x": 1, + "y": 7, + "average_household_income_change": -1806.0966714650708, + "relative_household_income_change": 0.08357134950427492, + "population": 141405.8939185595 + }, + { + "constituency_code": "E14000000220", + "constituency_name": "Constituency 220", + "x": 2, + "y": 7, + "average_household_income_change": -528.2119277643891, + "relative_household_income_change": 0.4861389513239871, + "population": 71685.69023793048 + }, + { + "constituency_code": "E14000000221", + "constituency_name": "Constituency 221", + "x": 3, + "y": 7, + "average_household_income_change": 951.5333154614805, + "relative_household_income_change": -0.0, + "population": 79177.23029313763 + }, + { + "constituency_code": "E14000000222", + "constituency_name": "Constituency 222", + "x": 4, + "y": 7, + "average_household_income_change": 834.3379193338874, + "relative_household_income_change": 1e-12, + "population": 110931.01259918352 + }, + { + "constituency_code": "E14000000223", + "constituency_name": "Constituency 223", + "x": 5, + "y": 7, + "average_household_income_change": 1654.9743225435302, + "relative_household_income_change": -0.0, + "population": 144906.40272194232 + }, + { + "constituency_code": "E14000000224", + "constituency_name": "Constituency 224", + "x": 6, + "y": 7, + "average_household_income_change": 1855.1096996562494, + "relative_household_income_change": -3.5e-9, + "population": 66984.07968401432 + }, + { + "constituency_code": "E14000000225", + "constituency_name": "Constituency 225", + "x": null, + "y": null, + "average_household_income_change": 1113.0857713770702, + "relative_household_income_change": -3.5e-9, + "population": 60678.66657872668 + }, + { + "constituency_code": "E14000000226", + "constituency_name": "Constituency 226", + "x": 8, + "y": 7, + "average_household_income_change": -317.2806673045674, + "relative_household_income_change": 1e-12, + "population": 85565.2841368982 + }, + { + "constituency_code": "E14000000227", + "constituency_name": "Constituency 227", + "x": 9, + "y": 7, + "average_household_income_change": 146.19610700532658, + "relative_household_income_change": 0.0, + "population": 54058.96768105369 + }, + { + "constituency_code": "E14000000228", + "constituency_name": "Constituency 228", + "x": 10, + "y": 7, + "average_household_income_change": 1221.1828572635995, + "relative_household_income_change": -0.0, + "population": 72968.59443746644 + }, + { + "constituency_code": "E14000000229", + "constituency_name": "Constituency 229", + "x": 11, + "y": 7, + "average_household_income_change": -474.6414251653745, + "relative_household_income_change": 0.44878570088574854, + "population": 90285.80408850596 + }, + { + "constituency_code": "E14000000230", + "constituency_name": "Constituency 230", + "x": 12, + "y": 7, + "average_household_income_change": -71.76530955441463, + "relative_household_income_change": 0.5436024592612985, + "population": 141221.01934312264 + }, + { + "constituency_code": "E14000000231", + "constituency_name": "Constituency 231", + "x": 13, + "y": 7, + "average_household_income_change": -378.30934127288975, + "relative_household_income_change": -3.5e-9, + "population": 116505.42531287429 + }, + { + "constituency_code": "E14000000232", + "constituency_name": "Constituency 232", + "x": null, + "y": null, + "average_household_income_change": 1005.3928185121252, + "relative_household_income_change": -0.0, + "population": 75058.25944668779 + }, + { + "constituency_code": "E14000000233", + "constituency_name": "Constituency 233", + "x": 15, + "y": 7, + "average_household_income_change": 1586.7343046544327, + "relative_household_income_change": 1e-12, + "population": 67712.64242455097 + }, + { + "constituency_code": "E14000000234", + "constituency_name": "Constituency 234", + "x": 16, + "y": 7, + "average_household_income_change": -1174.252830775441, + "relative_household_income_change": -3.5e-9, + "population": 63789.03547513046 + }, + { + "constituency_code": "E14000000235", + "constituency_name": "Constituency 235", + "x": 17, + "y": 7, + "average_household_income_change": 645.6604038115912, + "relative_household_income_change": 0.0, + "population": 122263.69384295912 + }, + { + "constituency_code": "E14000000236", + "constituency_name": "Constituency 236", + "x": 18, + "y": 7, + "average_household_income_change": -597.6576548607908, + "relative_household_income_change": -3.5e-9, + "population": 98804.04918650471 + }, + { + "constituency_code": "E14000000237", + "constituency_name": "Constituency 237", + "x": 19, + "y": 7, + "average_household_income_change": 415.38377283407124, + "relative_household_income_change": 1e-12, + "population": 72462.68658428223 + }, + { + "constituency_code": "E14000000238", + "constituency_name": "Constituency 238", + "x": 20, + "y": 7, + "average_household_income_change": -502.30148175641, + "relative_household_income_change": 0.39825500487354804, + "population": 75577.16208619578 + }, + { + "constituency_code": "E14000000239", + "constituency_name": "Constituency 239", + "x": null, + "y": null, + "average_household_income_change": 1982.22971422196, + "relative_household_income_change": 0.0, + "population": 100039.3452447531 + }, + { + "constituency_code": "E14000000240", + "constituency_name": "Constituency 240", + "x": 22, + "y": 7, + "average_household_income_change": 68.96842129588521, + "relative_household_income_change": 0.0, + "population": 78623.37294752918 + }, + { + "constituency_code": "E14000000241", + "constituency_name": "Constituency 241", + "x": 23, + "y": 7, + "average_household_income_change": -469.3900179759347, + "relative_household_income_change": -3.5e-9, + "population": 71536.75669369439 + }, + { + "constituency_code": "E14000000242", + "constituency_name": "Constituency 242", + "x": 24, + "y": 7, + "average_household_income_change": 1230.3207047092353, + "relative_household_income_change": -3.5e-9, + "population": 63353.07779126578 + }, + { + "constituency_code": "E14000000243", + "constituency_name": "Constituency 243", + "x": 25, + "y": 7, + "average_household_income_change": -1928.9219199088845, + "relative_household_income_change": -3.5e-9, + "population": 62849.86261697499 + }, + { + "constituency_code": "E14000000244", + "constituency_name": "Constituency 244", + "x": 26, + "y": 7, + "average_household_income_change": 1110.3372470128168, + "relative_household_income_change": -0.0, + "population": 76727.3810569975 + }, + { + "constituency_code": "E14000000245", + "constituency_name": "Constituency 245", + "x": 27, + "y": 7, + "average_household_income_change": 1362.7103896145973, + "relative_household_income_change": -0.0, + "population": 124325.70785994509 + }, + { + "constituency_code": "E14000000246", + "constituency_name": "Constituency 246", + "x": null, + "y": null, + "average_household_income_change": -271.49022713741465, + "relative_household_income_change": 1e-12, + "population": 99799.46872015242 + }, + { + "constituency_code": "E14000000247", + "constituency_name": "Constituency 247", + "x": 29, + "y": 7, + "average_household_income_change": 1537.7903349770033, + "relative_household_income_change": -3.5e-9, + "population": 123746.60837519364 + }, + { + "constituency_code": "E14000000248", + "constituency_name": "Constituency 248", + "x": 30, + "y": 7, + "average_household_income_change": -1087.8785006624473, + "relative_household_income_change": 1e-12, + "population": 97600.39659601363 + }, + { + "constituency_code": "E14000000249", + "constituency_name": "Constituency 249", + "x": 0, + "y": 8, + "average_household_income_change": -1345.7662153813862, + "relative_household_income_change": 0.0, + "population": 114313.09710587711 + }, + { + "constituency_code": "E14000000250", + "constituency_name": "Constituency 250", + "x": 1, + "y": 8, + "average_household_income_change": 1658.6777073773708, + "relative_household_income_change": -0.0, + "population": 94609.60109811788 + }, + { + "constituency_code": "E14000000251", + "constituency_name": "Constituency 251", + "x": 2, + "y": 8, + "average_household_income_change": -519.6173843357359, + "relative_household_income_change": 0.05288350383949525, + "population": 101879.58740965034 + }, + { + "constituency_code": "E14000000252", + "constituency_name": "Constituency 252", + "x": 3, + "y": 8, + "average_household_income_change": 61.39724136454517, + "relative_household_income_change": 0.0, + "population": 122016.25936999946 + }, + { + "constituency_code": "E14000000253", + "constituency_name": "Constituency 253", + "x": null, + "y": null, + "average_household_income_change": 200.79508742853758, + "relative_household_income_change": 1e-12, + "population": 112382.16718067882 + }, + { + "constituency_code": "E14000000254", + "constituency_name": "Constituency 254", + "x": 5, + "y": 8, + "average_household_income_change": 68.46081134368464, + "relative_household_income_change": 1e-12, + "population": 79034.69952823792 + }, + { + "constituency_code": "E14000000255", + "constituency_name": "Constituency 255", + "x": 6, + "y": 8, + "average_household_income_change": -1049.8419544678975, + "relative_household_income_change": 0.0685689670992683, + "population": 84627.21548030981 + }, + { + "constituency_code": "E14000000256", + "constituency_name": "Constituency 256", + "x": 7, + "y": 8, + "average_household_income_change": 1881.2593648861452, + "relative_household_income_change": -3.5e-9, + "population": 135723.15777362182 + }, + { + "constituency_code": "E14000000257", + "constituency_name": "Constituency 257", + "x": 8, + "y": 8, + "average_household_income_change": -300.59480412605757, + "relative_household_income_change": 0.38686768114256065, + "population": 140550.79135966688 + }, + { + "constituency_code": "E14000000258", + "constituency_name": "Constituency 258", + "x": 9, + "y": 8, + "average_household_income_change": -299.27228807240124, + "relative_household_income_change": -3.5e-9, + "population": 136940.02453417546 + }, + { + "constituency_code": "E14000000259", + "constituency_name": "Constituency 259", + "x": 10, + "y": 8, + "average_household_income_change": -1968.8831338393782, + "relative_household_income_change": 1e-12, + "population": 116862.35781095125 + }, + { + "constituency_code": "E14000000260", + "constituency_name": "Constituency 260", + "x": null, + "y": null, + "average_household_income_change": -1937.396708083625, + "relative_household_income_change": -0.0, + "population": 53050.95960456516 + }, + { + "constituency_code": "E14000000261", + "constituency_name": "Constituency 261", + "x": 12, + "y": 8, + "average_household_income_change": 278.54418344988784, + "relative_household_income_change": 0.8182049447786189, + "population": 145996.30964651122 + }, + { + "constituency_code": "E14000000262", + "constituency_name": "Constituency 262", + "x": 13, + "y": 8, + "average_household_income_change": -1743.60096454641, + "relative_household_income_change": -3.5e-9, + "population": 137665.3909221181 + }, + { + "constituency_code": "E14000000263", + "constituency_name": "Constituency 263", + "x": 14, + "y": 8, + "average_household_income_change": 410.3648552033592, + "relative_household_income_change": 0.0, + "population": 130733.25893769521 + }, + { + "constituency_code": "E14000000264", + "constituency_name": "Constituency 264", + "x": 15, + "y": 8, + "average_household_income_change": -870.6543863626357, + "relative_household_income_change": 0.0, + "population": 130659.0095046039 + }, + { + "constituency_code": "E14000000265", + "constituency_name": "Constituency 265", + "x": 16, + "y": 8, + "average_household_income_change": 1143.162831284736, + "relative_household_income_change": 0.0, + "population": 107575.18022024026 + }, + { + "constituency_code": "E14000000266", + "constituency_name": "Constituency 266", + "x": 17, + "y": 8, + "average_household_income_change": -1057.9486970304836, + "relative_household_income_change": -0.0, + "population": 91442.6613340457 + }, + { + "constituency_code": "E14000000267", + "constituency_name": "Constituency 267", + "x": null, + "y": null, + "average_household_income_change": 1814.0649747416485, + "relative_household_income_change": 1e-12, + "population": 65130.079693553234 + }, + { + "constituency_code": "E14000000268", + "constituency_name": "Constituency 268", + "x": 19, + "y": 8, + "average_household_income_change": -1516.7977290124063, + "relative_household_income_change": -0.0, + "population": 55364.656447213056 + }, + { + "constituency_code": "E14000000269", + "constituency_name": "Constituency 269", + "x": 20, + "y": 8, + "average_household_income_change": -294.20877227662066, + "relative_household_income_change": -0.0, + "population": 61555.40574473531 + }, + { + "constituency_code": "E14000000270", + "constituency_name": "Constituency 270", + "x": 21, + "y": 8, + "average_household_income_change": 695.5018348863717, + "relative_household_income_change": -0.0, + "population": 68812.25148927994 + }, + { + "constituency_code": "E14000000271", + "constituency_name": "Constituency 271", + "x": 22, + "y": 8, + "average_household_income_change": -351.1802166259006, + "relative_household_income_change": -0.0, + "population": 54027.65899145163 + }, + { + "constituency_code": "E14000000272", + "constituency_name": "Constituency 272", + "x": 23, + "y": 8, + "average_household_income_change": -1642.2915725524824, + "relative_household_income_change": 0.8379817398377877, + "population": 88079.14001836749 + }, + { + "constituency_code": "E14000000273", + "constituency_name": "Constituency 273", + "x": 24, + "y": 8, + "average_household_income_change": -196.16611907612392, + "relative_household_income_change": 0.6191859545022685, + "population": 143901.09379379006 + }, + { + "constituency_code": "E14000000274", + "constituency_name": "Constituency 274", + "x": null, + "y": null, + "average_household_income_change": 1592.5511593747626, + "relative_household_income_change": 1e-12, + "population": 56538.644750654064 + }, + { + "constituency_code": "E14000000275", + "constituency_name": "Constituency 275", + "x": 26, + "y": 8, + "average_household_income_change": 1249.8308107022656, + "relative_household_income_change": 0.0, + "population": 82852.1573553915 + }, + { + "constituency_code": "E14000000276", + "constituency_name": "Constituency 276", + "x": 27, + "y": 8, + "average_household_income_change": -1679.6035271853639, + "relative_household_income_change": 1e-12, + "population": 82624.31099995461 + }, + { + "constituency_code": "E14000000277", + "constituency_name": "Constituency 277", + "x": 28, + "y": 8, + "average_household_income_change": 1081.625804541158, + "relative_household_income_change": 0.7119632280929965, + "population": 56373.70772158275 + }, + { + "constituency_code": "E14000000278", + "constituency_name": "Constituency 278", + "x": 29, + "y": 8, + "average_household_income_change": -1514.008414001978, + "relative_household_income_change": -3.5e-9, + "population": 54038.13587168238 + }, + { + "constituency_code": "E14000000279", + "constituency_name": "Constituency 279", + "x": 30, + "y": 8, + "average_household_income_change": -879.656036805668, + "relative_household_income_change": 0.0, + "population": 66920.7276918206 + }, + { + "constituency_code": "E14000000280", + "constituency_name": "Constituency 280", + "x": 0, + "y": 9, + "average_household_income_change": -1434.2729900398488, + "relative_household_income_change": -3.5e-9, + "population": 114525.67044788442 + }, + { + "constituency_code": "E14000000281", + "constituency_name": "Constituency 281", + "x": null, + "y": null, + "average_household_income_change": 78.84229063710268, + "relative_household_income_change": 0.9093867952915566, + "population": 71203.42519173468 + }, + { + "constituency_code": "E14000000282", + "constituency_name": "Constituency 282", + "x": 2, + "y": 9, + "average_household_income_change": 12.239268357744137, + "relative_household_income_change": -0.0, + "population": 57133.2670657449 + }, + { + "constituency_code": "E14000000283", + "constituency_name": "Constituency 283", + "x": 3, + "y": 9, + "average_household_income_change": -879.1966266472486, + "relative_household_income_change": -3.5e-9, + "population": 117950.37248663565 + }, + { + "constituency_code": "E14000000284", + "constituency_name": "Constituency 284", + "x": 4, + "y": 9, + "average_household_income_change": 731.6369198544824, + "relative_household_income_change": 0.0, + "population": 60677.670160915164 + }, + { + "constituency_code": "E14000000285", + "constituency_name": "Constituency 285", + "x": 5, + "y": 9, + "average_household_income_change": 538.8786664582988, + "relative_household_income_change": 0.0, + "population": 134931.71481241478 + }, + { + "constituency_code": "E14000000286", + "constituency_name": "Constituency 286", + "x": 6, + "y": 9, + "average_household_income_change": -363.61423705336284, + "relative_household_income_change": -3.5e-9, + "population": 59397.05353299759 + }, + { + "constituency_code": "E14000000287", + "constituency_name": "Constituency 287", + "x": 7, + "y": 9, + "average_household_income_change": -41.56094887664574, + "relative_household_income_change": -0.0, + "population": 101221.93183884669 + }, + { + "constituency_code": "E14000000288", + "constituency_name": "Constituency 288", + "x": null, + "y": null, + "average_household_income_change": -1640.8394237733885, + "relative_household_income_change": -0.0, + "population": 85331.20565330614 + }, + { + "constituency_code": "E14000000289", + "constituency_name": "Constituency 289", + "x": 9, + "y": 9, + "average_household_income_change": 1607.6675640232882, + "relative_household_income_change": 0.0, + "population": 54725.222443885046 + }, + { + "constituency_code": "E14000000290", + "constituency_name": "Constituency 290", + "x": 10, + "y": 9, + "average_household_income_change": -1442.7349429318213, + "relative_household_income_change": 1e-12, + "population": 119504.45090787341 + }, + { + "constituency_code": "E14000000291", + "constituency_name": "Constituency 291", + "x": 11, + "y": 9, + "average_household_income_change": -1298.611495104644, + "relative_household_income_change": -0.0, + "population": 124055.42495705158 + }, + { + "constituency_code": "E14000000292", + "constituency_name": "Constituency 292", + "x": 12, + "y": 9, + "average_household_income_change": -363.63670045492836, + "relative_household_income_change": -0.0, + "population": 80797.82277151337 + }, + { + "constituency_code": "E14000000293", + "constituency_name": "Constituency 293", + "x": 13, + "y": 9, + "average_household_income_change": 902.118169666097, + "relative_household_income_change": 0.5975509379823232, + "population": 129073.02446683323 + }, + { + "constituency_code": "E14000000294", + "constituency_name": "Constituency 294", + "x": 14, + "y": 9, + "average_household_income_change": -139.25701863002172, + "relative_household_income_change": 0.06255641858639482, + "population": 57752.53085066069 + }, + { + "constituency_code": "E14000000295", + "constituency_name": "Constituency 295", + "x": null, + "y": null, + "average_household_income_change": -1163.432231007708, + "relative_household_income_change": 0.8764210555336815, + "population": 106333.06119563997 + }, + { + "constituency_code": "E14000000296", + "constituency_name": "Constituency 296", + "x": 16, + "y": 9, + "average_household_income_change": -1679.9787950530365, + "relative_household_income_change": 1e-12, + "population": 75692.39275484561 + }, + { + "constituency_code": "E14000000297", + "constituency_name": "Constituency 297", + "x": 17, + "y": 9, + "average_household_income_change": 1582.697326893069, + "relative_household_income_change": 0.0, + "population": 117975.52461036804 + }, + { + "constituency_code": "E14000000298", + "constituency_name": "Constituency 298", + "x": 18, + "y": 9, + "average_household_income_change": -141.68662686740774, + "relative_household_income_change": 0.0, + "population": 147122.08267887653 + }, + { + "constituency_code": "E14000000299", + "constituency_name": "Constituency 299", + "x": 19, + "y": 9, + "average_household_income_change": 248.44524455147445, + "relative_household_income_change": 0.0, + "population": 57914.21355725573 + }, + { + "constituency_code": "E14000000300", + "constituency_name": "Constituency 300", + "x": 20, + "y": 9, + "average_household_income_change": -1435.1194306235277, + "relative_household_income_change": 1e-12, + "population": 89667.12520548898 + }, + { + "constituency_code": "E14000000301", + "constituency_name": "Constituency 301", + "x": 21, + "y": 9, + "average_household_income_change": 1093.4980272431533, + "relative_household_income_change": 0.0, + "population": 149640.69785386953 + }, + { + "constituency_code": "E14000000302", + "constituency_name": "Constituency 302", + "x": null, + "y": null, + "average_household_income_change": 53.08937001870451, + "relative_household_income_change": -0.0, + "population": 66230.07359490372 + }, + { + "constituency_code": "E14000000303", + "constituency_name": "Constituency 303", + "x": 23, + "y": 9, + "average_household_income_change": -471.8832314261454, + "relative_household_income_change": -3.5e-9, + "population": 57337.41263366653 + }, + { + "constituency_code": "E14000000304", + "constituency_name": "Constituency 304", + "x": 24, + "y": 9, + "average_household_income_change": -1026.020840673814, + "relative_household_income_change": 1e-12, + "population": 142490.88938975122 + }, + { + "constituency_code": "E14000000305", + "constituency_name": "Constituency 305", + "x": 25, + "y": 9, + "average_household_income_change": 608.8165690306914, + "relative_household_income_change": 0.6404988087018922, + "population": 72487.89624498294 + }, + { + "constituency_code": "E14000000306", + "constituency_name": "Constituency 306", + "x": 26, + "y": 9, + "average_household_income_change": -115.80972292330483, + "relative_household_income_change": 0.39081883301841813, + "population": 77350.48896896599 + }, + { + "constituency_code": "E14000000307", + "constituency_name": "Constituency 307", + "x": 27, + "y": 9, + "average_household_income_change": -1591.2097568655606, + "relative_household_income_change": -0.0, + "population": 130601.76627288382 + }, + { + "constituency_code": "E14000000308", + "constituency_name": "Constituency 308", + "x": 28, + "y": 9, + "average_household_income_change": -202.68597089062146, + "relative_household_income_change": -3.5e-9, + "population": 127803.58575356533 + }, + { + "constituency_code": "E14000000309", + "constituency_name": "Constituency 309", + "x": null, + "y": null, + "average_household_income_change": -1703.293029455986, + "relative_household_income_change": 0.0, + "population": 96967.75627127332 + }, + { + "constituency_code": "E14000000310", + "constituency_name": "Constituency 310", + "x": 30, + "y": 9, + "average_household_income_change": -831.6689332469039, + "relative_household_income_change": -3.5e-9, + "population": 115309.39370340896 + }, + { + "constituency_code": "E14000000311", + "constituency_name": "Constituency 311", + "x": 0, + "y": 10, + "average_household_income_change": 1870.4055118959807, + "relative_household_income_change": 0.0, + "population": 83882.90139684646 + }, + { + "constituency_code": "E14000000312", + "constituency_name": "Constituency 312", + "x": 1, + "y": 10, + "average_household_income_change": 491.49040529432983, + "relative_household_income_change": -0.0, + "population": 80790.58693080283 + }, + { + "constituency_code": "E14000000313", + "constituency_name": "Constituency 313", + "x": 2, + "y": 10, + "average_household_income_change": 375.3827408162542, + "relative_household_income_change": 1e-12, + "population": 84326.10208188766 + }, + { + "constituency_code": "E14000000314", + "constituency_name": "Constituency 314", + "x": 3, + "y": 10, + "average_household_income_change": 33.000174958145664, + "relative_household_income_change": 0.5409158451119593, + "population": 133101.32286826705 + }, + { + "constituency_code": "E14000000315", + "constituency_name": "Constituency 315", + "x": 4, + "y": 10, + "average_household_income_change": -1338.6782648550195, + "relative_household_income_change": 1e-12, + "population": 135673.8907982766 + }, + { + "constituency_code": "E14000000316", + "constituency_name": "Constituency 316", + "x": null, + "y": null, + "average_household_income_change": -645.4749752199311, + "relative_household_income_change": 0.0, + "population": 53154.68071281595 + }, + { + "constituency_code": "E14000000317", + "constituency_name": "Constituency 317", + "x": 6, + "y": 10, + "average_household_income_change": 875.2196826351822, + "relative_household_income_change": -0.0, + "population": 54663.23250214491 + }, + { + "constituency_code": "E14000000318", + "constituency_name": "Constituency 318", + "x": 7, + "y": 10, + "average_household_income_change": -1906.3685553236708, + "relative_household_income_change": -0.0, + "population": 62216.10372183205 + }, + { + "constituency_code": "E14000000319", + "constituency_name": "Constituency 319", + "x": 8, + "y": 10, + "average_household_income_change": 265.67730367256326, + "relative_household_income_change": 0.07095713112290669, + "population": 123272.98162748596 + }, + { + "constituency_code": "E14000000320", + "constituency_name": "Constituency 320", + "x": 9, + "y": 10, + "average_household_income_change": 957.6221647457519, + "relative_household_income_change": -3.5e-9, + "population": 117320.59667338144 + }, + { + "constituency_code": "E14000000321", + "constituency_name": "Constituency 321", + "x": 10, + "y": 10, + "average_household_income_change": 892.5971352867052, + "relative_household_income_change": 0.01547062226303364, + "population": 64382.017243272196 + }, + { + "constituency_code": "E14000000322", + "constituency_name": "Constituency 322", + "x": 11, + "y": 10, + "average_household_income_change": -89.5120196189252, + "relative_household_income_change": 0.6415075542058354, + "population": 125144.8692163065 + }, + { + "constituency_code": "E14000000323", + "constituency_name": "Constituency 323", + "x": null, + "y": null, + "average_household_income_change": 1811.2694791851518, + "relative_household_income_change": -3.5e-9, + "population": 65712.70150761414 + }, + { + "constituency_code": "E14000000324", + "constituency_name": "Constituency 324", + "x": 13, + "y": 10, + "average_household_income_change": -1100.7471361008027, + "relative_household_income_change": 0.4185108779894693, + "population": 114836.3220960143 + }, + { + "constituency_code": "E14000000325", + "constituency_name": "Constituency 325", + "x": 14, + "y": 10, + "average_household_income_change": -1122.6043621770434, + "relative_household_income_change": 1e-12, + "population": 55237.5686532306 + }, + { + "constituency_code": "E14000000326", + "constituency_name": "Constituency 326", + "x": 15, + "y": 10, + "average_household_income_change": -1382.4413984731443, + "relative_household_income_change": -0.0, + "population": 94786.54853619754 + }, + { + "constituency_code": "E14000000327", + "constituency_name": "Constituency 327", + "x": 16, + "y": 10, + "average_household_income_change": -624.9672122114114, + "relative_household_income_change": 0.8086293754051761, + "population": 91180.08167511584 + }, + { + "constituency_code": "E14000000328", + "constituency_name": "Constituency 328", + "x": 17, + "y": 10, + "average_household_income_change": -1532.5080712305694, + "relative_household_income_change": 1e-12, + "population": 74992.84884821136 + }, + { + "constituency_code": "E14000000329", + "constituency_name": "Constituency 329", + "x": 18, + "y": 10, + "average_household_income_change": 1256.8611941470808, + "relative_household_income_change": -0.0, + "population": 73264.68467329515 + }, + { + "constituency_code": "E14000000330", + "constituency_name": "Constituency 330", + "x": null, + "y": null, + "average_household_income_change": 1460.216582001018, + "relative_household_income_change": -0.0, + "population": 120762.21259691025 + }, + { + "constituency_code": "E14000000331", + "constituency_name": "Constituency 331", + "x": 20, + "y": 10, + "average_household_income_change": 1131.1627205329526, + "relative_household_income_change": -3.5e-9, + "population": 124414.09849910664 + }, + { + "constituency_code": "E14000000332", + "constituency_name": "Constituency 332", + "x": 21, + "y": 10, + "average_household_income_change": -950.333632645132, + "relative_household_income_change": 0.30068912666377334, + "population": 102386.62156113084 + }, + { + "constituency_code": "E14000000333", + "constituency_name": "Constituency 333", + "x": 22, + "y": 10, + "average_household_income_change": -662.1771673166976, + "relative_household_income_change": -3.5e-9, + "population": 99217.79434560373 + }, + { + "constituency_code": "E14000000334", + "constituency_name": "Constituency 334", + "x": 23, + "y": 10, + "average_household_income_change": -310.1144178009183, + "relative_household_income_change": 0.0, + "population": 82642.85198374328 + }, + { + "constituency_code": "E14000000335", + "constituency_name": "Constituency 335", + "x": 24, + "y": 10, + "average_household_income_change": -98.61823883872577, + "relative_household_income_change": 0.24680610402164405, + "population": 101928.52381666923 + }, + { + "constituency_code": "E14000000336", + "constituency_name": "Constituency 336", + "x": 25, + "y": 10, + "average_household_income_change": 1800.075825253157, + "relative_household_income_change": 0.0, + "population": 106604.47201418565 + }, + { + "constituency_code": "E14000000337", + "constituency_name": "Constituency 337", + "x": null, + "y": null, + "average_household_income_change": -1024.5596852965332, + "relative_household_income_change": 0.5282743216274547, + "population": 84831.59483429573 + }, + { + "constituency_code": "E14000000338", + "constituency_name": "Constituency 338", + "x": 27, + "y": 10, + "average_household_income_change": -1559.9200409332993, + "relative_household_income_change": 0.0, + "population": 132190.56010856107 + }, + { + "constituency_code": "E14000000339", + "constituency_name": "Constituency 339", + "x": 28, + "y": 10, + "average_household_income_change": -322.5458070710423, + "relative_household_income_change": 0.22529688748048748, + "population": 106479.0884301016 + }, + { + "constituency_code": "E14000000340", + "constituency_name": "Constituency 340", + "x": 29, + "y": 10, + "average_household_income_change": 975.9731193891134, + "relative_household_income_change": 0.9228591687698418, + "population": 147412.05136602212 + }, + { + "constituency_code": "E14000000341", + "constituency_name": "Constituency 341", + "x": 30, + "y": 10, + "average_household_income_change": 1222.6290457869818, + "relative_household_income_change": 0.0, + "population": 144225.48837876614 + }, + { + "constituency_code": "E14000000342", + "constituency_name": "Constituency 342", + "x": 0, + "y": 11, + "average_household_income_change": -314.19552200388875, + "relative_household_income_change": 1e-12, + "population": 111129.0169869452 + }, + { + "constituency_code": "E14000000343", + "constituency_name": "Constituency 343", + "x": 1, + "y": 11, + "average_household_income_change": 1337.6914447481315, + "relative_household_income_change": 0.6064418096250256, + "population": 72777.5551270252 + }, + { + "constituency_code": "E14000000344", + "constituency_name": "Constituency 344", + "x": null, + "y": null, + "average_household_income_change": -500.28648215289894, + "relative_household_income_change": 0.02539666980792299, + "population": 140300.06720143737 + }, + { + "constituency_code": "E14000000345", + "constituency_name": "Constituency 345", + "x": 3, + "y": 11, + "average_household_income_change": -899.8299632125404, + "relative_household_income_change": -3.5e-9, + "population": 118550.12291309344 + }, + { + "constituency_code": "E14000000346", + "constituency_name": "Constituency 346", + "x": 4, + "y": 11, + "average_household_income_change": -824.48101449791, + "relative_household_income_change": 0.05691560918933902, + "population": 84746.18474953297 + }, + { + "constituency_code": "E14000000347", + "constituency_name": "Constituency 347", + "x": 5, + "y": 11, + "average_household_income_change": 1360.4905042331795, + "relative_household_income_change": 1e-12, + "population": 115209.82974713409 + }, + { + "constituency_code": "E14000000348", + "constituency_name": "Constituency 348", + "x": 6, + "y": 11, + "average_household_income_change": 1775.1536699727137, + "relative_household_income_change": 0.11514487999565826, + "population": 80112.3937259131 + }, + { + "constituency_code": "E14000000349", + "constituency_name": "Constituency 349", + "x": 7, + "y": 11, + "average_household_income_change": 1707.8194320756604, + "relative_household_income_change": 1e-12, + "population": 76833.07357783025 + }, + { + "constituency_code": "E14000000350", + "constituency_name": "Constituency 350", + "x": 8, + "y": 11, + "average_household_income_change": 1499.4873663114822, + "relative_household_income_change": 1e-12, + "population": 102963.34481014374 + }, + { + "constituency_code": "E14000000351", + "constituency_name": "Constituency 351", + "x": null, + "y": null, + "average_household_income_change": -845.488142728954, + "relative_household_income_change": 0.0, + "population": 77060.88122496438 + }, + { + "constituency_code": "E14000000352", + "constituency_name": "Constituency 352", + "x": 10, + "y": 11, + "average_household_income_change": 895.7933970570076, + "relative_household_income_change": 1e-12, + "population": 121186.05852623229 + }, + { + "constituency_code": "E14000000353", + "constituency_name": "Constituency 353", + "x": 11, + "y": 11, + "average_household_income_change": 436.30185633340534, + "relative_household_income_change": -3.5e-9, + "population": 90359.39372869075 + }, + { + "constituency_code": "E14000000354", + "constituency_name": "Constituency 354", + "x": 12, + "y": 11, + "average_household_income_change": 1531.899304402959, + "relative_household_income_change": -3.5e-9, + "population": 125773.89335858136 + }, + { + "constituency_code": "E14000000355", + "constituency_name": "Constituency 355", + "x": 13, + "y": 11, + "average_household_income_change": 1900.3636110963519, + "relative_household_income_change": -3.5e-9, + "population": 86402.77703380983 + }, + { + "constituency_code": "E14000000356", + "constituency_name": "Constituency 356", + "x": 14, + "y": 11, + "average_household_income_change": 990.6809907292611, + "relative_household_income_change": 1e-12, + "population": 133926.5278974796 + }, + { + "constituency_code": "E14000000357", + "constituency_name": "Constituency 357", + "x": 15, + "y": 11, + "average_household_income_change": -102.81392002473177, + "relative_household_income_change": 1e-12, + "population": 91672.73914310064 + }, + { + "constituency_code": "E14000000358", + "constituency_name": "Constituency 358", + "x": null, + "y": null, + "average_household_income_change": 1413.2362731585495, + "relative_household_income_change": 0.11842765300769598, + "population": 131869.28448096052 + }, + { + "constituency_code": "E14000000359", + "constituency_name": "Constituency 359", + "x": 17, + "y": 11, + "average_household_income_change": -1526.3658607536765, + "relative_household_income_change": 1e-12, + "population": 76359.1774732786 + }, + { + "constituency_code": "E14000000360", + "constituency_name": "Constituency 360", + "x": 18, + "y": 11, + "average_household_income_change": -1032.2988351211718, + "relative_household_income_change": -3.5e-9, + "population": 81960.88765276896 + }, + { + "constituency_code": "E14000000361", + "constituency_name": "Constituency 361", + "x": 19, + "y": 11, + "average_household_income_change": 906.6356976421935, + "relative_household_income_change": 0.5017789670349119, + "population": 57719.93054066484 + }, + { + "constituency_code": "E14000000362", + "constituency_name": "Constituency 362", + "x": 20, + "y": 11, + "average_household_income_change": -1671.2986473147962, + "relative_household_income_change": -0.0, + "population": 113415.38429893683 + }, + { + "constituency_code": "E14000000363", + "constituency_name": "Constituency 363", + "x": 21, + "y": 11, + "average_household_income_change": 111.27087975723614, + "relative_household_income_change": 0.0, + "population": 68079.6212173605 + }, + { + "constituency_code": "E14000000364", + "constituency_name": "Constituency 364", + "x": 22, + "y": 11, + "average_household_income_change": -781.90725602331, + "relative_household_income_change": -0.0, + "population": 67864.18906912404 + }, + { + "constituency_code": "E14000000365", + "constituency_name": "Constituency 365", + "x": null, + "y": null, + "average_household_income_change": 305.0824783887256, + "relative_household_income_change": 0.4660933581476817, + "population": 61334.79792201664 + }, + { + "constituency_code": "E14000000366", + "constituency_name": "Constituency 366", + "x": 24, + "y": 11, + "average_household_income_change": 724.4525151520427, + "relative_household_income_change": 1e-12, + "population": 106024.91759136216 + }, + { + "constituency_code": "E14000000367", + "constituency_name": "Constituency 367", + "x": 25, + "y": 11, + "average_household_income_change": -1702.5549476447566, + "relative_household_income_change": 0.11056314456087024, + "population": 81926.19788223934 + }, + { + "constituency_code": "E14000000368", + "constituency_name": "Constituency 368", + "x": 26, + "y": 11, + "average_household_income_change": -1931.3909280413527, + "relative_household_income_change": 1e-12, + "population": 71770.85701884079 + }, + { + "constituency_code": "E14000000369", + "constituency_name": "Constituency 369", + "x": 27, + "y": 11, + "average_household_income_change": 792.0308313732289, + "relative_household_income_change": 1e-12, + "population": 50440.10731257584 + }, + { + "constituency_code": "E14000000370", + "constituency_name": "Constituency 370", + "x": 28, + "y": 11, + "average_household_income_change": 425.9231978760969, + "relative_household_income_change": 0.7075709754638798, + "population": 112090.1619356483 + }, + { + "constituency_code": "E14000000371", + "constituency_name": "Constituency 371", + "x": 29, + "y": 11, + "average_household_income_change": -1814.3519191458379, + "relative_household_income_change": 0.8091499706226665, + "population": 52356.510651433506 + }, + { + "constituency_code": "E14000000372", + "constituency_name": "Constituency 372", + "x": null, + "y": null, + "average_household_income_change": 1109.08621756159, + "relative_household_income_change": -0.0, + "population": 83600.63778977693 + }, + { + "constituency_code": "E14000000373", + "constituency_name": "Constituency 373", + "x": 0, + "y": 12, + "average_household_income_change": 19.22110165496216, + "relative_household_income_change": 0.5146548251212197, + "population": 66507.908891974 + }, + { + "constituency_code": "E14000000374", + "constituency_name": "Constituency 374", + "x": 1, + "y": 12, + "average_household_income_change": -749.5772000344579, + "relative_household_income_change": -3.5e-9, + "population": 146792.99486028682 + }, + { + "constituency_code": "E14000000375", + "constituency_name": "Constituency 375", + "x": 2, + "y": 12, + "average_household_income_change": -783.8395562619974, + "relative_household_income_change": 1e-12, + "population": 65422.167957346835 + }, + { + "constituency_code": "E14000000376", + "constituency_name": "Constituency 376", + "x": 3, + "y": 12, + "average_household_income_change": -1450.1221650989824, + "relative_household_income_change": 0.0, + "population": 85866.87293099564 + }, + { + "constituency_code": "E14000000377", + "constituency_name": "Constituency 377", + "x": 4, + "y": 12, + "average_household_income_change": -645.798670669981, + "relative_household_income_change": 0.4976229122040582, + "population": 122889.59326278509 + }, + { + "constituency_code": "E14000000378", + "constituency_name": "Constituency 378", + "x": 5, + "y": 12, + "average_household_income_change": 1635.1724174774276, + "relative_household_income_change": 0.0, + "population": 140388.28343393683 + }, + { + "constituency_code": "E14000000379", + "constituency_name": "Constituency 379", + "x": null, + "y": null, + "average_household_income_change": -1322.91414940455, + "relative_household_income_change": 1e-12, + "population": 130180.15071380371 + }, + { + "constituency_code": "E14000000380", + "constituency_name": "Constituency 380", + "x": 7, + "y": 12, + "average_household_income_change": 643.6955982549462, + "relative_household_income_change": 1e-12, + "population": 141937.50754987824 + }, + { + "constituency_code": "E14000000381", + "constituency_name": "Constituency 381", + "x": 8, + "y": 12, + "average_household_income_change": -1775.2096638418507, + "relative_household_income_change": 0.43429070796214286, + "population": 145004.63125557202 + }, + { + "constituency_code": "E14000000382", + "constituency_name": "Constituency 382", + "x": 9, + "y": 12, + "average_household_income_change": 1754.3529428875963, + "relative_household_income_change": 1e-12, + "population": 82461.35902260432 + }, + { + "constituency_code": "E14000000383", + "constituency_name": "Constituency 383", + "x": 10, + "y": 12, + "average_household_income_change": 1134.450520364727, + "relative_household_income_change": 1e-12, + "population": 146435.07039696595 + }, + { + "constituency_code": "E14000000384", + "constituency_name": "Constituency 384", + "x": 11, + "y": 12, + "average_household_income_change": -1935.1463493486115, + "relative_household_income_change": 0.32313335925070485, + "population": 58004.102496374035 + }, + { + "constituency_code": "E14000000385", + "constituency_name": "Constituency 385", + "x": 12, + "y": 12, + "average_household_income_change": -209.05028104926782, + "relative_household_income_change": 0.6186153260113829, + "population": 117034.62816551719 + }, + { + "constituency_code": "E14000000386", + "constituency_name": "Constituency 386", + "x": null, + "y": null, + "average_household_income_change": 1694.0160478141113, + "relative_household_income_change": -0.0, + "population": 89858.43775105188 + }, + { + "constituency_code": "E14000000387", + "constituency_name": "Constituency 387", + "x": 14, + "y": 12, + "average_household_income_change": 1203.3637271579084, + "relative_household_income_change": -3.5e-9, + "population": 147699.35190383965 + }, + { + "constituency_code": "E14000000388", + "constituency_name": "Constituency 388", + "x": 15, + "y": 12, + "average_household_income_change": -1041.2816219126073, + "relative_household_income_change": 0.7761888702877715, + "population": 140566.11583849424 + }, + { + "constituency_code": "E14000000389", + "constituency_name": "Constituency 389", + "x": 16, + "y": 12, + "average_household_income_change": 1963.4883502536732, + "relative_household_income_change": 0.15856590673397397, + "population": 99208.27114640783 + }, + { + "constituency_code": "E14000000390", + "constituency_name": "Constituency 390", + "x": 17, + "y": 12, + "average_household_income_change": -541.6746768853842, + "relative_household_income_change": -0.0, + "population": 146739.93179938247 + }, + { + "constituency_code": "E14000000391", + "constituency_name": "Constituency 391", + "x": 18, + "y": 12, + "average_household_income_change": 392.3345088407983, + "relative_household_income_change": -3.5e-9, + "population": 110310.79494795081 + }, + { + "constituency_code": "E14000000392", + "constituency_name": "Constituency 392", + "x": 19, + "y": 12, + "average_household_income_change": -1394.3824785730135, + "relative_household_income_change": 0.0, + "population": 91123.9105954635 + }, + { + "constituency_code": "E14000000393", + "constituency_name": "Constituency 393", + "x": null, + "y": null, + "average_household_income_change": 1886.147031653585, + "relative_household_income_change": 1e-12, + "population": 127053.70056596707 + }, + { + "constituency_code": "E14000000394", + "constituency_name": "Constituency 394", + "x": 21, + "y": 12, + "average_household_income_change": -764.3913703477714, + "relative_household_income_change": 0.0, + "population": 74690.3431446662 + }, + { + "constituency_code": "E14000000395", + "constituency_name": "Constituency 395", + "x": 22, + "y": 12, + "average_household_income_change": -412.86388601931725, + "relative_household_income_change": -0.0, + "population": 91152.69472270005 + }, + { + "constituency_code": "E14000000396", + "constituency_name": "Constituency 396", + "x": 23, + "y": 12, + "average_household_income_change": -523.5367854396005, + "relative_household_income_change": -0.0, + "population": 96083.32104944291 + }, + { + "constituency_code": "E14000000397", + "constituency_name": "Constituency 397", + "x": 24, + "y": 12, + "average_household_income_change": -438.4916791503861, + "relative_household_income_change": 0.36857333601514064, + "population": 65334.83510074024 + }, + { + "constituency_code": "E14000000398", + "constituency_name": "Constituency 398", + "x": 25, + "y": 12, + "average_household_income_change": -1489.3255914403437, + "relative_household_income_change": -0.0, + "population": 75879.3948917247 + }, + { + "constituency_code": "E14000000399", + "constituency_name": "Constituency 399", + "x": 26, + "y": 12, + "average_household_income_change": 1280.5129456302825, + "relative_household_income_change": 0.0, + "population": 140421.26314416766 + }, + { + "constituency_code": "E14000000400", + "constituency_name": "Constituency 400", + "x": null, + "y": null, + "average_household_income_change": 838.1710243074423, + "relative_household_income_change": 1e-12, + "population": 102164.8608901718 + }, + { + "constituency_code": "E14000000401", + "constituency_name": "Constituency 401", + "x": 28, + "y": 12, + "average_household_income_change": -1785.5002054920485, + "relative_household_income_change": -0.0, + "population": 87580.2271317752 + }, + { + "constituency_code": "E14000000402", + "constituency_name": "Constituency 402", + "x": 29, + "y": 12, + "average_household_income_change": -689.4004480676439, + "relative_household_income_change": 0.6742375088852286, + "population": 81438.53883171464 + }, + { + "constituency_code": "E14000000403", + "constituency_name": "Constituency 403", + "x": 30, + "y": 12, + "average_household_income_change": -393.4628128033797, + "relative_household_income_change": -0.0, + "population": 75039.98922314639 + }, + { + "constituency_code": "E14000000404", + "constituency_name": "Constituency 404", + "x": 0, + "y": 13, + "average_household_income_change": 1565.0837804033047, + "relative_household_income_change": 0.0, + "population": 142227.87444765354 + }, + { + "constituency_code": "E14000000405", + "constituency_name": "Constituency 405", + "x": 1, + "y": 13, + "average_household_income_change": -337.8714605234876, + "relative_household_income_change": -3.5e-9, + "population": 77203.57983346927 + }, + { + "constituency_code": "E14000000406", + "constituency_name": "Constituency 406", + "x": 2, + "y": 13, + "average_household_income_change": -1055.3789197118797, + "relative_household_income_change": 0.4078556824436944, + "population": 136422.76200474886 + }, + { + "constituency_code": "E14000000407", + "constituency_name": "Constituency 407", + "x": null, + "y": null, + "average_household_income_change": 834.0527556961325, + "relative_household_income_change": -3.5e-9, + "population": 97354.9639783863 + }, + { + "constituency_code": "E14000000408", + "constituency_name": "Constituency 408", + "x": 4, + "y": 13, + "average_household_income_change": 134.09988432838736, + "relative_household_income_change": -3.5e-9, + "population": 119310.48812370429 + }, + { + "constituency_code": "E14000000409", + "constituency_name": "Constituency 409", + "x": 5, + "y": 13, + "average_household_income_change": 710.0191188423665, + "relative_household_income_change": -3.5e-9, + "population": 104900.49608990214 + }, + { + "constituency_code": "E14000000410", + "constituency_name": "Constituency 410", + "x": 6, + "y": 13, + "average_household_income_change": -1733.3123895866431, + "relative_household_income_change": -3.5e-9, + "population": 72416.44538586303 + }, + { + "constituency_code": "E14000000411", + "constituency_name": "Constituency 411", + "x": 7, + "y": 13, + "average_household_income_change": 1328.957189349265, + "relative_household_income_change": 1e-12, + "population": 55101.62863024679 + }, + { + "constituency_code": "E14000000412", + "constituency_name": "Constituency 412", + "x": 8, + "y": 13, + "average_household_income_change": 864.9534307001913, + "relative_household_income_change": 0.25864922967497583, + "population": 82769.6707088191 + }, + { + "constituency_code": "E14000000413", + "constituency_name": "Constituency 413", + "x": 9, + "y": 13, + "average_household_income_change": -1015.563802054348, + "relative_household_income_change": 0.5566808097245476, + "population": 115698.84677273533 + }, + { + "constituency_code": "E14000000414", + "constituency_name": "Constituency 414", + "x": null, + "y": null, + "average_household_income_change": 1966.0549435935516, + "relative_household_income_change": -3.5e-9, + "population": 63072.63158724338 + }, + { + "constituency_code": "E14000000415", + "constituency_name": "Constituency 415", + "x": 11, + "y": 13, + "average_household_income_change": -1719.4482496747435, + "relative_household_income_change": 1e-12, + "population": 122400.05252854996 + }, + { + "constituency_code": "E14000000416", + "constituency_name": "Constituency 416", + "x": 12, + "y": 13, + "average_household_income_change": -637.0025407932425, + "relative_household_income_change": -0.0, + "population": 117953.03763761363 + }, + { + "constituency_code": "E14000000417", + "constituency_name": "Constituency 417", + "x": 13, + "y": 13, + "average_household_income_change": -576.1912016158312, + "relative_household_income_change": -0.0, + "population": 109259.17191042229 + }, + { + "constituency_code": "E14000000418", + "constituency_name": "Constituency 418", + "x": 14, + "y": 13, + "average_household_income_change": -1004.6878933670484, + "relative_household_income_change": -3.5e-9, + "population": 91871.97834788615 + }, + { + "constituency_code": "E14000000419", + "constituency_name": "Constituency 419", + "x": 15, + "y": 13, + "average_household_income_change": -1937.3634334742933, + "relative_household_income_change": 0.0, + "population": 115991.17237357103 + }, + { + "constituency_code": "E14000000420", + "constituency_name": "Constituency 420", + "x": 16, + "y": 13, + "average_household_income_change": -88.33450719112923, + "relative_household_income_change": 0.19477066794069864, + "population": 138651.37596406846 + }, + { + "constituency_code": "E14000000421", + "constituency_name": "Constituency 421", + "x": null, + "y": null, + "average_household_income_change": -1783.8506563658334, + "relative_household_income_change": -0.0, + "population": 119996.31865550006 + }, + { + "constituency_code": "E14000000422", + "constituency_name": "Constituency 422", + "x": 18, + "y": 13, + "average_household_income_change": -124.97928653299084, + "relative_household_income_change": -0.0, + "population": 103284.53004234466 + }, + { + "constituency_code": "E14000000423", + "constituency_name": "Constituency 423", + "x": 19, + "y": 13, + "average_household_income_change": 746.9717492722236, + "relative_household_income_change": 0.0, + "population": 64314.765602602485 + }, + { + "constituency_code": "E14000000424", + "constituency_name": "Constituency 424", + "x": 20, + "y": 13, + "average_household_income_change": -400.21091127364275, + "relative_household_income_change": -3.5e-9, + "population": 126140.46522738156 + }, + { + "constituency_code": "E14000000425", + "constituency_name": "Constituency 425", + "x": 21, + "y": 13, + "average_household_income_change": -8.615886541627926, + "relative_household_income_change": 1e-12, + "population": 120500.20874521669 + }, + { + "constituency_code": "E14000000426", + "constituency_name": "Constituency 426", + "x": 22, + "y": 13, + "average_household_income_change": -1445.5321322845048, + "relative_household_income_change": 0.9992850483781154, + "population": 78517.46219775046 + }, + { + "constituency_code": "E14000000427", + "constituency_name": "Constituency 427", + "x": 23, + "y": 13, + "average_household_income_change": 259.7025064134226, + "relative_household_income_change": -3.5e-9, + "population": 134534.28932349032 + }, + { + "constituency_code": "E14000000428", + "constituency_name": "Constituency 428", + "x": null, + "y": null, + "average_household_income_change": 1353.384121501907, + "relative_household_income_change": -3.5e-9, + "population": 65122.60724567148 + }, + { + "constituency_code": "E14000000429", + "constituency_name": "Constituency 429", + "x": 25, + "y": 13, + "average_household_income_change": -1202.1846279558727, + "relative_household_income_change": 0.4375422625855504, + "population": 106516.88841622724 + }, + { + "constituency_code": "E14000000430", + "constituency_name": "Constituency 430", + "x": 26, + "y": 13, + "average_household_income_change": -965.9386032117636, + "relative_household_income_change": -3.5e-9, + "population": 77348.31795576304 + }, + { + "constituency_code": "E14000000431", + "constituency_name": "Constituency 431", + "x": 27, + "y": 13, + "average_household_income_change": -399.31020340847834, + "relative_household_income_change": -0.0, + "population": 120119.21276118392 + }, + { + "constituency_code": "E14000000432", + "constituency_name": "Constituency 432", + "x": 28, + "y": 13, + "average_household_income_change": -1501.7510086178102, + "relative_household_income_change": -3.5e-9, + "population": 123543.00333458264 + }, + { + "constituency_code": "E14000000433", + "constituency_name": "Constituency 433", + "x": 29, + "y": 13, + "average_household_income_change": 1871.3633908875686, + "relative_household_income_change": -0.0, + "population": 114188.50689344354 + }, + { + "constituency_code": "E14000000434", + "constituency_name": "Constituency 434", + "x": 30, + "y": 13, + "average_household_income_change": -878.9359766848924, + "relative_household_income_change": 0.0, + "population": 137125.66547882586 + }, + { + "constituency_code": "E14000000435", + "constituency_name": "Constituency 435", + "x": null, + "y": null, + "average_household_income_change": 22.292631722566902, + "relative_household_income_change": -3.5e-9, + "population": 81603.38693974496 + }, + { + "constituency_code": "E14000000436", + "constituency_name": "Constituency 436", + "x": 1, + "y": 14, + "average_household_income_change": -858.5202969894166, + "relative_household_income_change": -3.5e-9, + "population": 134844.320946582 + }, + { + "constituency_code": "E14000000437", + "constituency_name": "Constituency 437", + "x": 2, + "y": 14, + "average_household_income_change": 1108.7892653049796, + "relative_household_income_change": 0.7974146129248088, + "population": 109738.3225739174 + }, + { + "constituency_code": "E14000000438", + "constituency_name": "Constituency 438", + "x": 3, + "y": 14, + "average_household_income_change": -1938.1221811323512, + "relative_household_income_change": -3.5e-9, + "population": 133395.10392182146 + }, + { + "constituency_code": "E14000000439", + "constituency_name": "Constituency 439", + "x": 4, + "y": 14, + "average_household_income_change": 519.5332360344125, + "relative_household_income_change": -0.0, + "population": 110056.89208575683 + }, + { + "constituency_code": "E14000000440", + "constituency_name": "Constituency 440", + "x": 5, + "y": 14, + "average_household_income_change": 1201.6281562769175, + "relative_household_income_change": 0.6330995821885036, + "population": 139221.9302277582 + }, + { + "constituency_code": "E14000000441", + "constituency_name": "Constituency 441", + "x": 6, + "y": 14, + "average_household_income_change": 1411.4779232097058, + "relative_household_income_change": -0.0, + "population": 98613.22805672791 + }, + { + "constituency_code": "E14000000442", + "constituency_name": "Constituency 442", + "x": null, + "y": null, + "average_household_income_change": -1849.2825797469372, + "relative_household_income_change": 1e-12, + "population": 114277.94687681618 + }, + { + "constituency_code": "E14000000443", + "constituency_name": "Constituency 443", + "x": 8, + "y": 14, + "average_household_income_change": 1479.1634504108054, + "relative_household_income_change": -0.0, + "population": 55027.6507208199 + }, + { + "constituency_code": "E14000000444", + "constituency_name": "Constituency 444", + "x": 9, + "y": 14, + "average_household_income_change": -312.3263436457391, + "relative_household_income_change": 0.5903607041796785, + "population": 134005.43950487245 + }, + { + "constituency_code": "E14000000445", + "constituency_name": "Constituency 445", + "x": 10, + "y": 14, + "average_household_income_change": -235.72010774130314, + "relative_household_income_change": -0.0, + "population": 107152.43583785828 + }, + { + "constituency_code": "E14000000446", + "constituency_name": "Constituency 446", + "x": 11, + "y": 14, + "average_household_income_change": 277.1497954833628, + "relative_household_income_change": 0.5135397749456319, + "population": 70104.98439053618 + }, + { + "constituency_code": "E14000000447", + "constituency_name": "Constituency 447", + "x": 12, + "y": 14, + "average_household_income_change": 1828.3291736397134, + "relative_household_income_change": 0.0, + "population": 105758.01403771316 + }, + { + "constituency_code": "E14000000448", + "constituency_name": "Constituency 448", + "x": 13, + "y": 14, + "average_household_income_change": -1577.4235955150164, + "relative_household_income_change": -0.0, + "population": 84769.36787383328 + }, + { + "constituency_code": "E14000000449", + "constituency_name": "Constituency 449", + "x": null, + "y": null, + "average_household_income_change": -1375.9862826114268, + "relative_household_income_change": -3.5e-9, + "population": 68155.11585838869 + }, + { + "constituency_code": "E14000000450", + "constituency_name": "Constituency 450", + "x": 15, + "y": 14, + "average_household_income_change": 1619.7588245435081, + "relative_household_income_change": 1e-12, + "population": 108529.78607189572 + }, + { + "constituency_code": "E14000000451", + "constituency_name": "Constituency 451", + "x": 16, + "y": 14, + "average_household_income_change": -130.43885920074817, + "relative_household_income_change": -3.5e-9, + "population": 102164.24550005347 + }, + { + "constituency_code": "E14000000452", + "constituency_name": "Constituency 452", + "x": 17, + "y": 14, + "average_household_income_change": 1575.9769085769442, + "relative_household_income_change": -0.0, + "population": 83074.55414942687 + }, + { + "constituency_code": "E14000000453", + "constituency_name": "Constituency 453", + "x": 18, + "y": 14, + "average_household_income_change": -1376.5227309882753, + "relative_household_income_change": 1e-12, + "population": 76954.27379439081 + }, + { + "constituency_code": "E14000000454", + "constituency_name": "Constituency 454", + "x": 19, + "y": 14, + "average_household_income_change": 27.855930561574205, + "relative_household_income_change": -3.5e-9, + "population": 67970.8621895939 + }, + { + "constituency_code": "E14000000455", + "constituency_name": "Constituency 455", + "x": 20, + "y": 14, + "average_household_income_change": 255.4598736945679, + "relative_household_income_change": -0.0, + "population": 134897.99335933474 + }, + { + "constituency_code": "E14000000456", + "constituency_name": "Constituency 456", + "x": null, + "y": null, + "average_household_income_change": -1470.9287179798966, + "relative_household_income_change": -0.0, + "population": 110214.6845204571 + }, + { + "constituency_code": "E14000000457", + "constituency_name": "Constituency 457", + "x": 22, + "y": 14, + "average_household_income_change": -1065.357672584253, + "relative_household_income_change": 0.0, + "population": 85225.7969482269 + }, + { + "constituency_code": "E14000000458", + "constituency_name": "Constituency 458", + "x": 23, + "y": 14, + "average_household_income_change": 1826.5135187399758, + "relative_household_income_change": 1e-12, + "population": 98812.90540379459 + }, + { + "constituency_code": "E14000000459", + "constituency_name": "Constituency 459", + "x": 24, + "y": 14, + "average_household_income_change": -1758.258007451244, + "relative_household_income_change": 0.0, + "population": 120834.59886771583 + }, + { + "constituency_code": "E14000000460", + "constituency_name": "Constituency 460", + "x": 25, + "y": 14, + "average_household_income_change": 1661.4752599701808, + "relative_household_income_change": -3.5e-9, + "population": 140361.971015295 + }, + { + "constituency_code": "E14000000461", + "constituency_name": "Constituency 461", + "x": 26, + "y": 14, + "average_household_income_change": 843.8123826337538, + "relative_household_income_change": -0.0, + "population": 107298.60428240836 + }, + { + "constituency_code": "E14000000462", + "constituency_name": "Constituency 462", + "x": 27, + "y": 14, + "average_household_income_change": -261.89617854069394, + "relative_household_income_change": -0.0, + "population": 64880.04282406481 + }, + { + "constituency_code": "E14000000463", + "constituency_name": "Constituency 463", + "x": null, + "y": null, + "average_household_income_change": -362.59096189355546, + "relative_household_income_change": 0.0, + "population": 50746.49871824345 + }, + { + "constituency_code": "E14000000464", + "constituency_name": "Constituency 464", + "x": 29, + "y": 14, + "average_household_income_change": -1379.3381831933407, + "relative_household_income_change": -3.5e-9, + "population": 81843.30796123961 + }, + { + "constituency_code": "E14000000465", + "constituency_name": "Constituency 465", + "x": 30, + "y": 14, + "average_household_income_change": -895.9319061295998, + "relative_household_income_change": -0.0, + "population": 86307.51948753887 + }, + { + "constituency_code": "E14000000466", + "constituency_name": "Constituency 466", + "x": 0, + "y": 15, + "average_household_income_change": -1045.5240756289488, + "relative_household_income_change": 0.0, + "population": 106630.97285104157 + }, + { + "constituency_code": "E14000000467", + "constituency_name": "Constituency 467", + "x": 1, + "y": 15, + "average_household_income_change": -131.40113141275344, + "relative_household_income_change": 0.0, + "population": 85625.67572050777 + }, + { + "constituency_code": "E14000000468", + "constituency_name": "Constituency 468", + "x": 2, + "y": 15, + "average_household_income_change": -864.2228005114356, + "relative_household_income_change": 1e-12, + "population": 110692.75990094521 + }, + { + "constituency_code": "E14000000469", + "constituency_name": "Constituency 469", + "x": 3, + "y": 15, + "average_household_income_change": -1217.396077124258, + "relative_household_income_change": 1e-12, + "population": 95791.24250102474 + }, + { + "constituency_code": "E14000000470", + "constituency_name": "Constituency 470", + "x": null, + "y": null, + "average_household_income_change": 819.4095140623756, + "relative_household_income_change": -3.5e-9, + "population": 132591.75404400696 + }, + { + "constituency_code": "E14000000471", + "constituency_name": "Constituency 471", + "x": 5, + "y": 15, + "average_household_income_change": -677.4478721217139, + "relative_household_income_change": -0.0, + "population": 93372.07825937188 + }, + { + "constituency_code": "E14000000472", + "constituency_name": "Constituency 472", + "x": 6, + "y": 15, + "average_household_income_change": 769.2812919052758, + "relative_household_income_change": 0.35070114616556214, + "population": 82749.8384185931 + }, + { + "constituency_code": "E14000000473", + "constituency_name": "Constituency 473", + "x": 7, + "y": 15, + "average_household_income_change": 1826.5823371023187, + "relative_household_income_change": -0.0, + "population": 94452.51692962078 + }, + { + "constituency_code": "E14000000474", + "constituency_name": "Constituency 474", + "x": 8, + "y": 15, + "average_household_income_change": -854.2150106850004, + "relative_household_income_change": 0.0, + "population": 73725.82272334029 + }, + { + "constituency_code": "E14000000475", + "constituency_name": "Constituency 475", + "x": 9, + "y": 15, + "average_household_income_change": 1825.4016048874723, + "relative_household_income_change": 0.0, + "population": 77291.27368977931 + }, + { + "constituency_code": "E14000000476", + "constituency_name": "Constituency 476", + "x": 10, + "y": 15, + "average_household_income_change": 216.77639129025695, + "relative_household_income_change": 0.0, + "population": 77824.36965646 + }, + { + "constituency_code": "E14000000477", + "constituency_name": "Constituency 477", + "x": null, + "y": null, + "average_household_income_change": -1593.3122775483603, + "relative_household_income_change": -3.5e-9, + "population": 111493.02905492586 + }, + { + "constituency_code": "E14000000478", + "constituency_name": "Constituency 478", + "x": 12, + "y": 15, + "average_household_income_change": -1262.0158636617136, + "relative_household_income_change": -0.0, + "population": 57389.887600907605 + }, + { + "constituency_code": "E14000000479", + "constituency_name": "Constituency 479", + "x": 13, + "y": 15, + "average_household_income_change": -1737.3547076698944, + "relative_household_income_change": 0.4758874388681912, + "population": 146184.4538265504 + }, + { + "constituency_code": "E14000000480", + "constituency_name": "Constituency 480", + "x": 14, + "y": 15, + "average_household_income_change": -1337.146372116397, + "relative_household_income_change": -0.0, + "population": 99503.02345698366 + }, + { + "constituency_code": "E14000000481", + "constituency_name": "Constituency 481", + "x": 15, + "y": 15, + "average_household_income_change": -443.31641902452026, + "relative_household_income_change": 1e-12, + "population": 95722.83718255228 + }, + { + "constituency_code": "E14000000482", + "constituency_name": "Constituency 482", + "x": 16, + "y": 15, + "average_household_income_change": 500.8423169444973, + "relative_household_income_change": 1e-12, + "population": 93764.74125416626 + }, + { + "constituency_code": "E14000000483", + "constituency_name": "Constituency 483", + "x": 17, + "y": 15, + "average_household_income_change": -359.07471826969186, + "relative_household_income_change": 0.0, + "population": 138311.96152862016 + }, + { + "constituency_code": "E14000000484", + "constituency_name": "Constituency 484", + "x": null, + "y": null, + "average_household_income_change": 1395.8256437349855, + "relative_household_income_change": -0.0, + "population": 86636.26588590886 + }, + { + "constituency_code": "E14000000485", + "constituency_name": "Constituency 485", + "x": 19, + "y": 15, + "average_household_income_change": 957.414181435833, + "relative_household_income_change": -3.5e-9, + "population": 68778.62572790433 + }, + { + "constituency_code": "E14000000486", + "constituency_name": "Constituency 486", + "x": 20, + "y": 15, + "average_household_income_change": 52.068594084960566, + "relative_household_income_change": -0.0, + "population": 149629.3667680624 + }, + { + "constituency_code": "E14000000487", + "constituency_name": "Constituency 487", + "x": 21, + "y": 15, + "average_household_income_change": -464.85862856143, + "relative_household_income_change": -0.0, + "population": 110352.0278947326 + }, + { + "constituency_code": "E14000000488", + "constituency_name": "Constituency 488", + "x": 22, + "y": 15, + "average_household_income_change": 1764.0835881287157, + "relative_household_income_change": 1e-12, + "population": 62384.47166940049 + }, + { + "constituency_code": "E14000000489", + "constituency_name": "Constituency 489", + "x": 23, + "y": 15, + "average_household_income_change": 692.3998314092001, + "relative_household_income_change": 0.0, + "population": 85408.80666901525 + }, + { + "constituency_code": "E14000000490", + "constituency_name": "Constituency 490", + "x": 24, + "y": 15, + "average_household_income_change": 1601.0227417620797, + "relative_household_income_change": 0.3256023747916895, + "population": 127392.7504156333 + }, + { + "constituency_code": "E14000000491", + "constituency_name": "Constituency 491", + "x": null, + "y": null, + "average_household_income_change": -924.525713053443, + "relative_household_income_change": 1e-12, + "population": 115834.91080642746 + }, + { + "constituency_code": "E14000000492", + "constituency_name": "Constituency 492", + "x": 26, + "y": 15, + "average_household_income_change": 1607.7828851379104, + "relative_household_income_change": 0.08626010051598998, + "population": 116576.36483302494 + }, + { + "constituency_code": "E14000000493", + "constituency_name": "Constituency 493", + "x": 27, + "y": 15, + "average_household_income_change": 392.5900111746632, + "relative_household_income_change": -0.0, + "population": 132360.86716965502 + }, + { + "constituency_code": "E14000000494", + "constituency_name": "Constituency 494", + "x": 28, + "y": 15, + "average_household_income_change": 223.9340392642153, + "relative_household_income_change": -3.5e-9, + "population": 134846.52691992815 + }, + { + "constituency_code": "E14000000495", + "constituency_name": "Constituency 495", + "x": 29, + "y": 15, + "average_household_income_change": 1441.2988042773184, + "relative_household_income_change": 0.0, + "population": 137986.4965672183 + }, + { + "constituency_code": "E14000000496", + "constituency_name": "Constituency 496", + "x": 30, + "y": 15, + "average_household_income_change": -498.7039191058127, + "relative_household_income_change": 0.3252945301780501, + "population": 143405.9946706618 + }, + { + "constituency_code": "E14000000497", + "constituency_name": "Constituency 497", + "x": 0, + "y": 16, + "average_household_income_change": 1133.1963374325728, + "relative_household_income_change": 1e-12, + "population": 68426.46908976509 + }, + { + "constituency_code": "E14000000498", + "constituency_name": "Constituency 498", + "x": null, + "y": null, + "average_household_income_change": 1552.2279517645652, + "relative_household_income_change": 1e-12, + "population": 69801.21947574713 + }, + { + "constituency_code": "E14000000499", + "constituency_name": "Constituency 499", + "x": 2, + "y": 16, + "average_household_income_change": -1933.0121573332403, + "relative_household_income_change": -0.0, + "population": 71810.7127554828 + }, + { + "constituency_code": "E14000000500", + "constituency_name": "Constituency 500", + "x": 3, + "y": 16, + "average_household_income_change": 1856.020820102462, + "relative_household_income_change": -0.0, + "population": 115990.60962848747 + }, + { + "constituency_code": "E14000000501", + "constituency_name": "Constituency 501", + "x": 4, + "y": 16, + "average_household_income_change": 1939.131245149897, + "relative_household_income_change": 0.0, + "population": 145774.10814719292 + }, + { + "constituency_code": "E14000000502", + "constituency_name": "Constituency 502", + "x": 5, + "y": 16, + "average_household_income_change": 1644.716537294447, + "relative_household_income_change": -0.0, + "population": 115658.11807754858 + }, + { + "constituency_code": "E14000000503", + "constituency_name": "Constituency 503", + "x": 6, + "y": 16, + "average_household_income_change": -1636.9003200301875, + "relative_household_income_change": 0.0, + "population": 89531.70714848021 + }, + { + "constituency_code": "E14000000504", + "constituency_name": "Constituency 504", + "x": 7, + "y": 16, + "average_household_income_change": 88.92124000860102, + "relative_household_income_change": 1e-12, + "population": 125688.2659888468 + }, + { + "constituency_code": "E14000000505", + "constituency_name": "Constituency 505", + "x": null, + "y": null, + "average_household_income_change": -1318.7744512100821, + "relative_household_income_change": 0.0, + "population": 91088.20168102277 + }, + { + "constituency_code": "E14000000506", + "constituency_name": "Constituency 506", + "x": 9, + "y": 16, + "average_household_income_change": -1175.9495393146576, + "relative_household_income_change": 0.30616875596009807, + "population": 131214.95491923267 + }, + { + "constituency_code": "E14000000507", + "constituency_name": "Constituency 507", + "x": 10, + "y": 16, + "average_household_income_change": -1439.7064451060162, + "relative_household_income_change": -3.5e-9, + "population": 119035.7114330096 + }, + { + "constituency_code": "E14000000508", + "constituency_name": "Constituency 508", + "x": 11, + "y": 16, + "average_household_income_change": 525.9315378974767, + "relative_household_income_change": 0.0, + "population": 149780.60570252594 + }, + { + "constituency_code": "E14000000509", + "constituency_name": "Constituency 509", + "x": 12, + "y": 16, + "average_household_income_change": -1367.1976497187427, + "relative_household_income_change": 0.0, + "population": 99775.86963739073 + }, + { + "constituency_code": "E14000000510", + "constituency_name": "Constituency 510", + "x": 13, + "y": 16, + "average_household_income_change": 681.0185339591745, + "relative_household_income_change": 0.0, + "population": 52250.53683271075 + }, + { + "constituency_code": "E14000000511", + "constituency_name": "Constituency 511", + "x": 14, + "y": 16, + "average_household_income_change": 711.0597691914737, + "relative_household_income_change": -0.0, + "population": 73213.28057757275 + }, + { + "constituency_code": "E14000000512", + "constituency_name": "Constituency 512", + "x": null, + "y": null, + "average_household_income_change": 488.84935913679016, + "relative_household_income_change": 0.0, + "population": 125981.89147263978 + }, + { + "constituency_code": "E14000000513", + "constituency_name": "Constituency 513", + "x": 16, + "y": 16, + "average_household_income_change": -1196.9661233102288, + "relative_household_income_change": -3.5e-9, + "population": 128616.27263812006 + }, + { + "constituency_code": "E14000000514", + "constituency_name": "Constituency 514", + "x": 17, + "y": 16, + "average_household_income_change": 1539.7654878967505, + "relative_household_income_change": 1e-12, + "population": 104552.41760001461 + }, + { + "constituency_code": "E14000000515", + "constituency_name": "Constituency 515", + "x": 18, + "y": 16, + "average_household_income_change": 664.5980280202077, + "relative_household_income_change": 0.9157729795254148, + "population": 132900.03832707356 + }, + { + "constituency_code": "E14000000516", + "constituency_name": "Constituency 516", + "x": 19, + "y": 16, + "average_household_income_change": 874.7981354443475, + "relative_household_income_change": -0.0, + "population": 139599.38195812594 + }, + { + "constituency_code": "E14000000517", + "constituency_name": "Constituency 517", + "x": 20, + "y": 16, + "average_household_income_change": -92.51945756994174, + "relative_household_income_change": -0.0, + "population": 127203.73196954656 + }, + { + "constituency_code": "E14000000518", + "constituency_name": "Constituency 518", + "x": 21, + "y": 16, + "average_household_income_change": 1401.7863678742444, + "relative_household_income_change": 0.0, + "population": 101400.21509992852 + }, + { + "constituency_code": "E14000000519", + "constituency_name": "Constituency 519", + "x": null, + "y": null, + "average_household_income_change": 950.872054844548, + "relative_household_income_change": -3.5e-9, + "population": 102808.51547701252 + }, + { + "constituency_code": "E14000000520", + "constituency_name": "Constituency 520", + "x": 23, + "y": 16, + "average_household_income_change": 267.1213839802129, + "relative_household_income_change": 0.8801315917911949, + "population": 113871.08181816454 + }, + { + "constituency_code": "E14000000521", + "constituency_name": "Constituency 521", + "x": 24, + "y": 16, + "average_household_income_change": 1583.6668839155411, + "relative_household_income_change": -0.0, + "population": 76415.2914843896 + }, + { + "constituency_code": "E14000000522", + "constituency_name": "Constituency 522", + "x": 25, + "y": 16, + "average_household_income_change": 473.62702776804167, + "relative_household_income_change": -0.0, + "population": 110938.16395304399 + }, + { + "constituency_code": "E14000000523", + "constituency_name": "Constituency 523", + "x": 26, + "y": 16, + "average_household_income_change": 1661.2704907714462, + "relative_household_income_change": 1e-12, + "population": 86960.28366122275 + }, + { + "constituency_code": "E14000000524", + "constituency_name": "Constituency 524", + "x": 27, + "y": 16, + "average_household_income_change": 1677.6832707399083, + "relative_household_income_change": -0.0, + "population": 62627.66271913964 + }, + { + "constituency_code": "E14000000525", + "constituency_name": "Constituency 525", + "x": 28, + "y": 16, + "average_household_income_change": 32.65960657239475, + "relative_household_income_change": 0.8058113644644113, + "population": 88512.16871276736 + }, + { + "constituency_code": "E14000000526", + "constituency_name": "Constituency 526", + "x": null, + "y": null, + "average_household_income_change": 956.6026427007084, + "relative_household_income_change": 1e-12, + "population": 67177.90548895177 + }, + { + "constituency_code": "E14000000527", + "constituency_name": "Constituency 527", + "x": 30, + "y": 16, + "average_household_income_change": -79.65777433093812, + "relative_household_income_change": -0.0, + "population": 129359.15970497992 + }, + { + "constituency_code": "E14000000528", + "constituency_name": "Constituency 528", + "x": 0, + "y": 17, + "average_household_income_change": -1530.3875617109775, + "relative_household_income_change": -3.5e-9, + "population": 122544.81803253728 + }, + { + "constituency_code": "E14000000529", + "constituency_name": "Constituency 529", + "x": 1, + "y": 17, + "average_household_income_change": -690.1267696520264, + "relative_household_income_change": -0.0, + "population": 74964.53753227353 + }, + { + "constituency_code": "E14000000530", + "constituency_name": "Constituency 530", + "x": 2, + "y": 17, + "average_household_income_change": 1821.0119974157406, + "relative_household_income_change": -3.5e-9, + "population": 117617.45171258436 + }, + { + "constituency_code": "E14000000531", + "constituency_name": "Constituency 531", + "x": 3, + "y": 17, + "average_household_income_change": 208.0498046541739, + "relative_household_income_change": -3.5e-9, + "population": 143004.6731627013 + }, + { + "constituency_code": "E14000000532", + "constituency_name": "Constituency 532", + "x": 4, + "y": 17, + "average_household_income_change": 24.455900708818756, + "relative_household_income_change": 0.0, + "population": 74337.36080179275 + }, + { + "constituency_code": "E14000000533", + "constituency_name": "Constituency 533", + "x": null, + "y": null, + "average_household_income_change": 1983.1116832288517, + "relative_household_income_change": -3.5e-9, + "population": 117952.23903240418 + }, + { + "constituency_code": "E14000000534", + "constituency_name": "Constituency 534", + "x": 6, + "y": 17, + "average_household_income_change": 1027.6081219737403, + "relative_household_income_change": 0.6042977436890922, + "population": 122635.04116619783 + }, + { + "constituency_code": "E14000000535", + "constituency_name": "Constituency 535", + "x": 7, + "y": 17, + "average_household_income_change": 883.3729304827402, + "relative_household_income_change": -0.0, + "population": 84563.45632753313 + }, + { + "constituency_code": "E14000000536", + "constituency_name": "Constituency 536", + "x": 8, + "y": 17, + "average_household_income_change": -415.0102749434339, + "relative_household_income_change": -3.5e-9, + "population": 139019.39363636472 + }, + { + "constituency_code": "E14000000537", + "constituency_name": "Constituency 537", + "x": 9, + "y": 17, + "average_household_income_change": -929.7356907140188, + "relative_household_income_change": -3.5e-9, + "population": 62512.41742705785 + }, + { + "constituency_code": "E14000000538", + "constituency_name": "Constituency 538", + "x": 10, + "y": 17, + "average_household_income_change": -1996.7697301134376, + "relative_household_income_change": 0.0, + "population": 90369.34146142566 + }, + { + "constituency_code": "E14000000539", + "constituency_name": "Constituency 539", + "x": 11, + "y": 17, + "average_household_income_change": 1801.4507622204592, + "relative_household_income_change": 0.7875965623100697, + "population": 118552.27021021653 + }, + { + "constituency_code": "E14000000540", + "constituency_name": "Constituency 540", + "x": null, + "y": null, + "average_household_income_change": -568.5524660509858, + "relative_household_income_change": -3.5e-9, + "population": 129523.3537724613 + }, + { + "constituency_code": "E14000000541", + "constituency_name": "Constituency 541", + "x": 13, + "y": 17, + "average_household_income_change": 1272.6941414223477, + "relative_household_income_change": -0.0, + "population": 57640.64659864336 + }, + { + "constituency_code": "E14000000542", + "constituency_name": "Constituency 542", + "x": 14, + "y": 17, + "average_household_income_change": -767.5057915657742, + "relative_household_income_change": 0.3357304975304993, + "population": 115692.15832822133 + }, + { + "constituency_code": "E14000000543", + "constituency_name": "Constituency 543", + "x": 15, + "y": 17, + "average_household_income_change": 70.06268109612046, + "relative_household_income_change": 0.0, + "population": 87007.97793879958 + }, + { + "constituency_code": "E14000000544", + "constituency_name": "Constituency 544", + "x": 16, + "y": 17, + "average_household_income_change": 1966.6092641458092, + "relative_household_income_change": 0.0, + "population": 114343.38680480534 + }, + { + "constituency_code": "E14000000545", + "constituency_name": "Constituency 545", + "x": 17, + "y": 17, + "average_household_income_change": -350.98957610023353, + "relative_household_income_change": 1e-12, + "population": 121673.71687105054 + }, + { + "constituency_code": "E14000000546", + "constituency_name": "Constituency 546", + "x": 18, + "y": 17, + "average_household_income_change": 284.78850562685795, + "relative_household_income_change": -3.5e-9, + "population": 90107.91467113272 + }, + { + "constituency_code": "E14000000547", + "constituency_name": "Constituency 547", + "x": null, + "y": null, + "average_household_income_change": 540.7926305781476, + "relative_household_income_change": -0.0, + "population": 77857.17106791587 + }, + { + "constituency_code": "E14000000548", + "constituency_name": "Constituency 548", + "x": 20, + "y": 17, + "average_household_income_change": 1572.495720645288, + "relative_household_income_change": 1e-12, + "population": 63776.77103353226 + }, + { + "constituency_code": "E14000000549", + "constituency_name": "Constituency 549", + "x": 21, + "y": 17, + "average_household_income_change": 786.1815131891445, + "relative_household_income_change": 0.0, + "population": 72317.31785925994 + }, + { + "constituency_code": "E14000000550", + "constituency_name": "Constituency 550", + "x": 22, + "y": 17, + "average_household_income_change": 501.0373161137469, + "relative_household_income_change": -3.5e-9, + "population": 51968.97767258395 + }, + { + "constituency_code": "E14000000551", + "constituency_name": "Constituency 551", + "x": 23, + "y": 17, + "average_household_income_change": -872.3149541947821, + "relative_household_income_change": 0.0, + "population": 103860.54079116536 + }, + { + "constituency_code": "E14000000552", + "constituency_name": "Constituency 552", + "x": 24, + "y": 17, + "average_household_income_change": -1729.9601043319988, + "relative_household_income_change": 0.0, + "population": 144540.067456093 + }, + { + "constituency_code": "E14000000553", + "constituency_name": "Constituency 553", + "x": 25, + "y": 17, + "average_household_income_change": -1180.1590196030402, + "relative_household_income_change": 1e-12, + "population": 66543.61008387458 + }, + { + "constituency_code": "E14000000554", + "constituency_name": "Constituency 554", + "x": null, + "y": null, + "average_household_income_change": 58.6742524695163, + "relative_household_income_change": 1e-12, + "population": 141632.26250944004 + }, + { + "constituency_code": "E14000000555", + "constituency_name": "Constituency 555", + "x": 27, + "y": 17, + "average_household_income_change": -1379.002800676671, + "relative_household_income_change": 1e-12, + "population": 65107.374171665964 + }, + { + "constituency_code": "E14000000556", + "constituency_name": "Constituency 556", + "x": 28, + "y": 17, + "average_household_income_change": -1534.8529282878776, + "relative_household_income_change": -3.5e-9, + "population": 98585.83289728437 + }, + { + "constituency_code": "E14000000557", + "constituency_name": "Constituency 557", + "x": 29, + "y": 17, + "average_household_income_change": 1195.252276686837, + "relative_household_income_change": 0.0, + "population": 121134.06333908037 + }, + { + "constituency_code": "E14000000558", + "constituency_name": "Constituency 558", + "x": 30, + "y": 17, + "average_household_income_change": -587.1983257619266, + "relative_household_income_change": 1e-12, + "population": 56035.946122824156 + }, + { + "constituency_code": "E14000000559", + "constituency_name": "Constituency 559", + "x": 0, + "y": 18, + "average_household_income_change": -1329.7265899480517, + "relative_household_income_change": 0.0, + "population": 75557.55189169839 + }, + { + "constituency_code": "E14000000560", + "constituency_name": "Constituency 560", + "x": 1, + "y": 18, + "average_household_income_change": -1255.3949599405319, + "relative_household_income_change": 1e-12, + "population": 94964.38366509751 + }, + { + "constituency_code": "E14000000561", + "constituency_name": "Constituency 561", + "x": null, + "y": null, + "average_household_income_change": 1852.312025967675, + "relative_household_income_change": 0.0, + "population": 124320.79269576802 + }, + { + "constituency_code": "E14000000562", + "constituency_name": "Constituency 562", + "x": 3, + "y": 18, + "average_household_income_change": 1216.5399142250092, + "relative_household_income_change": -3.5e-9, + "population": 72214.34664091107 + }, + { + "constituency_code": "E14000000563", + "constituency_name": "Constituency 563", + "x": 4, + "y": 18, + "average_household_income_change": 936.0199128578968, + "relative_household_income_change": -0.0, + "population": 52121.195242426424 + }, + { + "constituency_code": "E14000000564", + "constituency_name": "Constituency 564", + "x": 5, + "y": 18, + "average_household_income_change": 460.4087216784598, + "relative_household_income_change": 0.0, + "population": 132579.04007382633 + }, + { + "constituency_code": "E14000000565", + "constituency_name": "Constituency 565", + "x": 6, + "y": 18, + "average_household_income_change": -1228.017075495524, + "relative_household_income_change": 1e-12, + "population": 127756.68581745084 + }, + { + "constituency_code": "E14000000566", + "constituency_name": "Constituency 566", + "x": 7, + "y": 18, + "average_household_income_change": 989.0043124308359, + "relative_household_income_change": 1e-12, + "population": 96691.3995080644 + }, + { + "constituency_code": "E14000000567", + "constituency_name": "Constituency 567", + "x": 8, + "y": 18, + "average_household_income_change": 633.2752797320067, + "relative_household_income_change": -3.5e-9, + "population": 105847.73106323073 + }, + { + "constituency_code": "E14000000568", + "constituency_name": "Constituency 568", + "x": null, + "y": null, + "average_household_income_change": 1537.6835760239028, + "relative_household_income_change": -0.0, + "population": 59574.52355411442 + }, + { + "constituency_code": "E14000000569", + "constituency_name": "Constituency 569", + "x": 10, + "y": 18, + "average_household_income_change": 564.9755937017145, + "relative_household_income_change": 0.4342081333144031, + "population": 122732.6148148665 + }, + { + "constituency_code": "E14000000570", + "constituency_name": "Constituency 570", + "x": 11, + "y": 18, + "average_household_income_change": 1901.1059243448528, + "relative_household_income_change": -3.5e-9, + "population": 86291.08878565823 + }, + { + "constituency_code": "E14000000571", + "constituency_name": "Constituency 571", + "x": 12, + "y": 18, + "average_household_income_change": 1647.0976068910384, + "relative_household_income_change": 0.0, + "population": 83118.92406031303 + }, + { + "constituency_code": "E14000000572", + "constituency_name": "Constituency 572", + "x": 13, + "y": 18, + "average_household_income_change": 917.6942423765499, + "relative_household_income_change": 0.7141296082160444, + "population": 83980.99861545017 + }, + { + "constituency_code": "E14000000573", + "constituency_name": "Constituency 573", + "x": 14, + "y": 18, + "average_household_income_change": -794.8516659664629, + "relative_household_income_change": -0.0, + "population": 116611.44894635049 + }, + { + "constituency_code": "E14000000574", + "constituency_name": "Constituency 574", + "x": 15, + "y": 18, + "average_household_income_change": 779.5916049469769, + "relative_household_income_change": 0.9569943470482749, + "population": 56838.49468248409 + }, + { + "constituency_code": "E14000000575", + "constituency_name": "Constituency 575", + "x": null, + "y": null, + "average_household_income_change": 1652.578569337204, + "relative_household_income_change": -0.0, + "population": 104940.63353614656 + }, + { + "constituency_code": "E14000000576", + "constituency_name": "Constituency 576", + "x": 17, + "y": 18, + "average_household_income_change": 307.4118121606677, + "relative_household_income_change": 1e-12, + "population": 126993.11969852142 + }, + { + "constituency_code": "E14000000577", + "constituency_name": "Constituency 577", + "x": 18, + "y": 18, + "average_household_income_change": 741.5070307134765, + "relative_household_income_change": 1e-12, + "population": 119936.8884747448 + }, + { + "constituency_code": "E14000000578", + "constituency_name": "Constituency 578", + "x": 19, + "y": 18, + "average_household_income_change": -293.01348432114384, + "relative_household_income_change": -0.0, + "population": 139054.8255490753 + }, + { + "constituency_code": "E14000000579", + "constituency_name": "Constituency 579", + "x": 20, + "y": 18, + "average_household_income_change": 1131.8770630588108, + "relative_household_income_change": 0.0, + "population": 72826.75061051587 + }, + { + "constituency_code": "E14000000580", + "constituency_name": "Constituency 580", + "x": 21, + "y": 18, + "average_household_income_change": -630.9564099040315, + "relative_household_income_change": -3.5e-9, + "population": 56888.615982387026 + }, + { + "constituency_code": "E14000000581", + "constituency_name": "Constituency 581", + "x": 22, + "y": 18, + "average_household_income_change": 1554.3278310363116, + "relative_household_income_change": 0.0, + "population": 145841.03237646338 + }, + { + "constituency_code": "E14000000582", + "constituency_name": "Constituency 582", + "x": null, + "y": null, + "average_household_income_change": 960.0847099843812, + "relative_household_income_change": -0.0, + "population": 137570.1463791927 + }, + { + "constituency_code": "E14000000583", + "constituency_name": "Constituency 583", + "x": 24, + "y": 18, + "average_household_income_change": 823.4577882062744, + "relative_household_income_change": 0.8625367572015296, + "population": 130636.04667489542 + }, + { + "constituency_code": "E14000000584", + "constituency_name": "Constituency 584", + "x": 25, + "y": 18, + "average_household_income_change": -70.09732998103146, + "relative_household_income_change": 0.0, + "population": 70802.83526568713 + }, + { + "constituency_code": "E14000000585", + "constituency_name": "Constituency 585", + "x": 26, + "y": 18, + "average_household_income_change": 1592.447556354229, + "relative_household_income_change": 1e-12, + "population": 60038.29592953278 + }, + { + "constituency_code": "E14000000586", + "constituency_name": "Constituency 586", + "x": 27, + "y": 18, + "average_household_income_change": 1561.290651214074, + "relative_household_income_change": 0.0, + "population": 123015.32138698698 + }, + { + "constituency_code": "E14000000587", + "constituency_name": "Constituency 587", + "x": 28, + "y": 18, + "average_household_income_change": 92.60827995758403, + "relative_household_income_change": -3.5e-9, + "population": 74372.69820872009 + }, + { + "constituency_code": "E14000000588", + "constituency_name": "Constituency 588", + "x": 29, + "y": 18, + "average_household_income_change": -1735.1883462164376, + "relative_household_income_change": 0.8652316608891197, + "population": 107990.85608582196 + }, + { + "constituency_code": "E14000000589", + "constituency_name": "Constituency 589", + "x": null, + "y": null, + "average_household_income_change": -731.794768168398, + "relative_household_income_change": -0.0, + "population": 133607.86126488214 + }, + { + "constituency_code": "E14000000590", + "constituency_name": "Constituency 590", + "x": 0, + "y": 19, + "average_household_income_change": 1124.1223787780555, + "relative_household_income_change": 0.9933078591651776, + "population": 55151.75031185687 + }, + { + "constituency_code": "E14000000591", + "constituency_name": "Constituency 591", + "x": 1, + "y": 19, + "average_household_income_change": -804.9034884896406, + "relative_household_income_change": 0.0, + "population": 132303.99221559628 + }, + { + "constituency_code": "E14000000592", + "constituency_name": "Constituency 592", + "x": 2, + "y": 19, + "average_household_income_change": -265.5169125780508, + "relative_household_income_change": -3.5e-9, + "population": 53366.22724187432 + }, + { + "constituency_code": "E14000000593", + "constituency_name": "Constituency 593", + "x": 3, + "y": 19, + "average_household_income_change": -1961.2404528225684, + "relative_household_income_change": 1e-12, + "population": 97782.92636483927 + }, + { + "constituency_code": "E14000000594", + "constituency_name": "Constituency 594", + "x": 4, + "y": 19, + "average_household_income_change": 477.65139708514994, + "relative_household_income_change": 1e-12, + "population": 84078.00961891914 + }, + { + "constituency_code": "E14000000595", + "constituency_name": "Constituency 595", + "x": 5, + "y": 19, + "average_household_income_change": -546.6949474395025, + "relative_household_income_change": 0.3385683294268179, + "population": 76236.94173217926 + }, + { + "constituency_code": "E14000000596", + "constituency_name": "Constituency 596", + "x": null, + "y": null, + "average_household_income_change": 1652.2575803141276, + "relative_household_income_change": -0.0, + "population": 89450.32745730813 + }, + { + "constituency_code": "E14000000597", + "constituency_name": "Constituency 597", + "x": 7, + "y": 19, + "average_household_income_change": -1775.7536371573299, + "relative_household_income_change": 0.0, + "population": 117045.28244465016 + }, + { + "constituency_code": "E14000000598", + "constituency_name": "Constituency 598", + "x": 8, + "y": 19, + "average_household_income_change": 766.7318410506416, + "relative_household_income_change": 0.0, + "population": 88213.62626352572 + }, + { + "constituency_code": "E14000000599", + "constituency_name": "Constituency 599", + "x": 9, + "y": 19, + "average_household_income_change": 1530.454228643761, + "relative_household_income_change": -0.0, + "population": 138297.81002117688 + }, + { + "constituency_code": "E14000000600", + "constituency_name": "Constituency 600", + "x": 10, + "y": 19, + "average_household_income_change": 1765.2977730020027, + "relative_household_income_change": 0.0, + "population": 139824.0752634297 + }, + { + "constituency_code": "E14000000601", + "constituency_name": "Constituency 601", + "x": 11, + "y": 19, + "average_household_income_change": 1223.2611077151216, + "relative_household_income_change": 1e-12, + "population": 113591.50698099248 + }, + { + "constituency_code": "E14000000602", + "constituency_name": "Constituency 602", + "x": 12, + "y": 19, + "average_household_income_change": 382.6683234379353, + "relative_household_income_change": 0.0, + "population": 141310.5086029322 + }, + { + "constituency_code": "E14000000603", + "constituency_name": "Constituency 603", + "x": null, + "y": null, + "average_household_income_change": 856.1768560114076, + "relative_household_income_change": 0.7630627694108344, + "population": 111736.58376417658 + }, + { + "constituency_code": "E14000000604", + "constituency_name": "Constituency 604", + "x": 14, + "y": 19, + "average_household_income_change": -383.73355517201844, + "relative_household_income_change": 0.0, + "population": 74431.454997639 + }, + { + "constituency_code": "E14000000605", + "constituency_name": "Constituency 605", + "x": 15, + "y": 19, + "average_household_income_change": 1189.128223761453, + "relative_household_income_change": 0.0, + "population": 96831.1726712148 + }, + { + "constituency_code": "E14000000606", + "constituency_name": "Constituency 606", + "x": 16, + "y": 19, + "average_household_income_change": 1918.0756490163558, + "relative_household_income_change": -0.0, + "population": 141947.63285895935 + }, + { + "constituency_code": "E14000000607", + "constituency_name": "Constituency 607", + "x": 17, + "y": 19, + "average_household_income_change": -357.7091821516629, + "relative_household_income_change": 0.0, + "population": 104614.10256123879 + }, + { + "constituency_code": "E14000000608", + "constituency_name": "Constituency 608", + "x": 18, + "y": 19, + "average_household_income_change": 628.4179318357687, + "relative_household_income_change": 1e-12, + "population": 103755.05655807689 + }, + { + "constituency_code": "E14000000609", + "constituency_name": "Constituency 609", + "x": 19, + "y": 19, + "average_household_income_change": -1248.9217949583033, + "relative_household_income_change": -0.0, + "population": 147521.3750362315 + }, + { + "constituency_code": "E14000000610", + "constituency_name": "Constituency 610", + "x": null, + "y": null, + "average_household_income_change": -1583.7983552457956, + "relative_household_income_change": 0.0, + "population": 51688.1449805309 + }, + { + "constituency_code": "E14000000611", + "constituency_name": "Constituency 611", + "x": 21, + "y": 19, + "average_household_income_change": 1327.7323629112625, + "relative_household_income_change": -0.0, + "population": 103884.21189524661 + }, + { + "constituency_code": "E14000000612", + "constituency_name": "Constituency 612", + "x": 22, + "y": 19, + "average_household_income_change": 1973.251067041317, + "relative_household_income_change": 1e-12, + "population": 83744.23359295663 + }, + { + "constituency_code": "E14000000613", + "constituency_name": "Constituency 613", + "x": 23, + "y": 19, + "average_household_income_change": 83.21324225082982, + "relative_household_income_change": -0.0, + "population": 68368.57302030973 + }, + { + "constituency_code": "E14000000614", + "constituency_name": "Constituency 614", + "x": 24, + "y": 19, + "average_household_income_change": -395.21045059080564, + "relative_household_income_change": 0.0, + "population": 80043.8086664578 + }, + { + "constituency_code": "E14000000615", + "constituency_name": "Constituency 615", + "x": 25, + "y": 19, + "average_household_income_change": 1307.3369463042873, + "relative_household_income_change": -0.0, + "population": 105144.40885855842 + }, + { + "constituency_code": "E14000000616", + "constituency_name": "Constituency 616", + "x": 26, + "y": 19, + "average_household_income_change": -1282.621906237099, + "relative_household_income_change": 0.4248049166319652, + "population": 128939.8907021561 + }, + { + "constituency_code": "E14000000617", + "constituency_name": "Constituency 617", + "x": null, + "y": null, + "average_household_income_change": 953.4736754873703, + "relative_household_income_change": 0.0, + "population": 103767.56011373061 + }, + { + "constituency_code": "E14000000618", + "constituency_name": "Constituency 618", + "x": 28, + "y": 19, + "average_household_income_change": -929.6915636162972, + "relative_household_income_change": 0.6774768027545954, + "population": 149677.9304957911 + }, + { + "constituency_code": "E14000000619", + "constituency_name": "Constituency 619", + "x": 29, + "y": 19, + "average_household_income_change": 771.5763025685698, + "relative_household_income_change": 0.0, + "population": 77227.88788830965 + }, + { + "constituency_code": "E14000000620", + "constituency_name": "Constituency 620", + "x": 30, + "y": 19, + "average_household_income_change": -766.5509814539178, + "relative_household_income_change": -3.5e-9, + "population": 82122.3677446993 + }, + { + "constituency_code": "E14000000621", + "constituency_name": "Constituency 621", + "x": 0, + "y": 20, + "average_household_income_change": -752.9484879757442, + "relative_household_income_change": -3.5e-9, + "population": 76081.76656371151 + }, + { + "constituency_code": "E14000000622", + "constituency_name": "Constituency 622", + "x": 1, + "y": 20, + "average_household_income_change": 1222.9442365982281, + "relative_household_income_change": 0.0, + "population": 120679.12954257091 + }, + { + "constituency_code": "E14000000623", + "constituency_name": "Constituency 623", + "x": 2, + "y": 20, + "average_household_income_change": -1432.8521114865923, + "relative_household_income_change": 0.9195260235316269, + "population": 119371.17541933047 + }, + { + "constituency_code": "E14000000624", + "constituency_name": "Constituency 624", + "x": null, + "y": null, + "average_household_income_change": 534.9241254942352, + "relative_household_income_change": 0.3338237028975374, + "population": 60955.649829977745 + }, + { + "constituency_code": "E14000000625", + "constituency_name": "Constituency 625", + "x": 4, + "y": 20, + "average_household_income_change": 1843.912890268073, + "relative_household_income_change": 1e-12, + "population": 129358.11465348065 + }, + { + "constituency_code": "E14000000626", + "constituency_name": "Constituency 626", + "x": 5, + "y": 20, + "average_household_income_change": 371.6653569909436, + "relative_household_income_change": 0.18625157984189045, + "population": 55491.321063149466 + }, + { + "constituency_code": "E14000000627", + "constituency_name": "Constituency 627", + "x": 6, + "y": 20, + "average_household_income_change": -327.3032084220029, + "relative_household_income_change": 1e-12, + "population": 105868.16394036285 + }, + { + "constituency_code": "E14000000628", + "constituency_name": "Constituency 628", + "x": 7, + "y": 20, + "average_household_income_change": -1493.6082836546789, + "relative_household_income_change": 1e-12, + "population": 92021.95979984169 + }, + { + "constituency_code": "E14000000629", + "constituency_name": "Constituency 629", + "x": 8, + "y": 20, + "average_household_income_change": -1699.9458449766842, + "relative_household_income_change": -0.0, + "population": 122067.3726304384 + }, + { + "constituency_code": "E14000000630", + "constituency_name": "Constituency 630", + "x": 9, + "y": 20, + "average_household_income_change": -450.5896596519281, + "relative_household_income_change": 1e-12, + "population": 52936.33348045289 + }, + { + "constituency_code": "E14000000631", + "constituency_name": "Constituency 631", + "x": null, + "y": null, + "average_household_income_change": -1377.8764440953496, + "relative_household_income_change": 1e-12, + "population": 123121.2733392203 + }, + { + "constituency_code": "E14000000632", + "constituency_name": "Constituency 632", + "x": 11, + "y": 20, + "average_household_income_change": -1051.2636353189384, + "relative_household_income_change": 0.0, + "population": 96399.63093497188 + }, + { + "constituency_code": "E14000000633", + "constituency_name": "Constituency 633", + "x": 12, + "y": 20, + "average_household_income_change": 14.147980833502327, + "relative_household_income_change": -0.0, + "population": 90394.5248879687 + }, + { + "constituency_code": "E14000000634", + "constituency_name": "Constituency 634", + "x": 13, + "y": 20, + "average_household_income_change": -1838.0730523928687, + "relative_household_income_change": 0.8712942895857928, + "population": 60641.30386365898 + }, + { + "constituency_code": "E14000000635", + "constituency_name": "Constituency 635", + "x": 14, + "y": 20, + "average_household_income_change": -87.23716684922378, + "relative_household_income_change": 0.0, + "population": 104747.24642102896 + }, + { + "constituency_code": "E14000000636", + "constituency_name": "Constituency 636", + "x": 15, + "y": 20, + "average_household_income_change": 1939.336244729525, + "relative_household_income_change": 0.0, + "population": 127050.64843988064 + }, + { + "constituency_code": "E14000000637", + "constituency_name": "Constituency 637", + "x": 16, + "y": 20, + "average_household_income_change": 868.1707597779589, + "relative_household_income_change": 0.987503057055717, + "population": 129088.10024620766 + }, + { + "constituency_code": "E14000000638", + "constituency_name": "Constituency 638", + "x": null, + "y": null, + "average_household_income_change": -1786.8649278672845, + "relative_household_income_change": -0.0, + "population": 123939.15459225708 + }, + { + "constituency_code": "E14000000639", + "constituency_name": "Constituency 639", + "x": 18, + "y": 20, + "average_household_income_change": -189.309168849606, + "relative_household_income_change": -0.0, + "population": 120874.07398615906 + }, + { + "constituency_code": "E14000000640", + "constituency_name": "Constituency 640", + "x": 19, + "y": 20, + "average_household_income_change": 1594.94324793443, + "relative_household_income_change": -0.0, + "population": 74818.70120486079 + }, + { + "constituency_code": "E14000000641", + "constituency_name": "Constituency 641", + "x": 20, + "y": 20, + "average_household_income_change": 396.5805294054912, + "relative_household_income_change": -3.5e-9, + "population": 107085.6435479035 + }, + { + "constituency_code": "E14000000642", + "constituency_name": "Constituency 642", + "x": 21, + "y": 20, + "average_household_income_change": -1988.8676478597636, + "relative_household_income_change": 0.7466795414572786, + "population": 68794.36517222921 + }, + { + "constituency_code": "E14000000643", + "constituency_name": "Constituency 643", + "x": 22, + "y": 20, + "average_household_income_change": -1416.3933519071054, + "relative_household_income_change": 0.4848194651534483, + "population": 60473.494553043936 + }, + { + "constituency_code": "E14000000644", + "constituency_name": "Constituency 644", + "x": 23, + "y": 20, + "average_household_income_change": 1249.393074236446, + "relative_household_income_change": -3.5e-9, + "population": 123993.994089221 + }, + { + "constituency_code": "E14000000645", + "constituency_name": "Constituency 645", + "x": null, + "y": null, + "average_household_income_change": 1046.8309597875534, + "relative_household_income_change": -3.5e-9, + "population": 82589.20612888796 + }, + { + "constituency_code": "E14000000646", + "constituency_name": "Constituency 646", + "x": 25, + "y": 20, + "average_household_income_change": 1062.21099433036, + "relative_household_income_change": 1e-12, + "population": 123517.19941423072 + }, + { + "constituency_code": "E14000000647", + "constituency_name": "Constituency 647", + "x": 26, + "y": 20, + "average_household_income_change": -313.8369092416631, + "relative_household_income_change": -3.5e-9, + "population": 144734.81087895617 + }, + { + "constituency_code": "E14000000648", + "constituency_name": "Constituency 648", + "x": 27, + "y": 20, + "average_household_income_change": 1658.9417288888621, + "relative_household_income_change": 0.0, + "population": 54363.82273350112 + }, + { + "constituency_code": "E14000000649", + "constituency_name": "Constituency 649", + "x": 28, + "y": 20, + "average_household_income_change": 446.986217100954, + "relative_household_income_change": 1e-12, + "population": 129264.05146024606 + }, + { + "constituency_code": "E14000000650", + "constituency_name": "Constituency 650", + "x": 29, + "y": 20, + "average_household_income_change": 317.6664618952065, + "relative_household_income_change": -3.5e-9, + "population": 56840.72937490232 + } + ], + "local_authority_impact": [ + { + "local_authority_code": "E06000000001", + "local_authority_name": "Local Authority 1", + "x": null, + "y": null, + "average_household_income_change": 468.5659066798089, + "relative_household_income_change": -3.5e-9, + "population": 100206.37490705009 + }, + { + "local_authority_code": "E06000000002", + "local_authority_name": "Local Authority 2", + "x": 1, + "y": 0, + "average_household_income_change": -69.21670028623072, + "relative_household_income_change": 1e-12, + "population": 123384.01709097778 + }, + { + "local_authority_code": "E06000000003", + "local_authority_name": "Local Authority 3", + "x": 2, + "y": 0, + "average_household_income_change": 912.1297309164911, + "relative_household_income_change": 0.0, + "population": 141131.81544042542 + }, + { + "local_authority_code": "E06000000004", + "local_authority_name": "Local Authority 4", + "x": 3, + "y": 0, + "average_household_income_change": 1910.1807474188765, + "relative_household_income_change": -0.0, + "population": 76099.80320995662 + }, + { + "local_authority_code": "E06000000005", + "local_authority_name": "Local Authority 5", + "x": 4, + "y": 0, + "average_household_income_change": 969.7714464687679, + "relative_household_income_change": 1e-12, + "population": 72911.01548143946 + }, + { + "local_authority_code": "E06000000006", + "local_authority_name": "Local Authority 6", + "x": 5, + "y": 0, + "average_household_income_change": -1207.175754642046, + "relative_household_income_change": 1e-12, + "population": 100754.01422780073 + }, + { + "local_authority_code": "E06000000007", + "local_authority_name": "Local Authority 7", + "x": 6, + "y": 0, + "average_household_income_change": 588.1157473314702, + "relative_household_income_change": -3.5e-9, + "population": 148197.15018666055 + }, + { + "local_authority_code": "E06000000008", + "local_authority_name": "Local Authority 8", + "x": null, + "y": null, + "average_household_income_change": -1779.658218255996, + "relative_household_income_change": 0.0, + "population": 128556.48672377989 + }, + { + "local_authority_code": "E06000000009", + "local_authority_name": "Local Authority 9", + "x": 8, + "y": 0, + "average_household_income_change": 212.13946976047592, + "relative_household_income_change": -0.0, + "population": 115700.32594875956 + }, + { + "local_authority_code": "E06000000010", + "local_authority_name": "Local Authority 10", + "x": 9, + "y": 0, + "average_household_income_change": -1378.918327125695, + "relative_household_income_change": -3.5e-9, + "population": 73114.63274971377 + }, + { + "local_authority_code": "E06000000011", + "local_authority_name": "Local Authority 11", + "x": 10, + "y": 0, + "average_household_income_change": -623.1384398880615, + "relative_household_income_change": -0.0, + "population": 139342.8006351117 + }, + { + "local_authority_code": "E06000000012", + "local_authority_name": "Local Authority 12", + "x": 11, + "y": 0, + "average_household_income_change": -1682.4344088991313, + "relative_household_income_change": 0.0, + "population": 132067.73320383328 + }, + { + "local_authority_code": "E06000000013", + "local_authority_name": "Local Authority 13", + "x": 12, + "y": 0, + "average_household_income_change": -1793.1064434964794, + "relative_household_income_change": -3.5e-9, + "population": 104826.38130177911 + }, + { + "local_authority_code": "E06000000014", + "local_authority_name": "Local Authority 14", + "x": 13, + "y": 0, + "average_household_income_change": -972.1255530448434, + "relative_household_income_change": -3.5e-9, + "population": 64127.86306468177 + }, + { + "local_authority_code": "E06000000015", + "local_authority_name": "Local Authority 15", + "x": null, + "y": null, + "average_household_income_change": 1687.1904190190778, + "relative_household_income_change": 0.19915732727299662, + "population": 82930.81346214596 + }, + { + "local_authority_code": "E06000000016", + "local_authority_name": "Local Authority 16", + "x": 15, + "y": 0, + "average_household_income_change": -538.4535593560624, + "relative_household_income_change": -0.0, + "population": 102310.46935923983 + }, + { + "local_authority_code": "E06000000017", + "local_authority_name": "Local Authority 17", + "x": 16, + "y": 0, + "average_household_income_change": 1745.5093965364488, + "relative_household_income_change": -0.0, + "population": 110324.97681835851 + }, + { + "local_authority_code": "E06000000018", + "local_authority_name": "Local Authority 18", + "x": 17, + "y": 0, + "average_household_income_change": -1613.72155725294, + "relative_household_income_change": 0.0, + "population": 88984.89875920078 + }, + { + "local_authority_code": "E06000000019", + "local_authority_name": "Local Authority 19", + "x": 18, + "y": 0, + "average_household_income_change": -1690.1448876694994, + "relative_household_income_change": -3.5e-9, + "population": 72890.3783981163 + }, + { + "local_authority_code": "E06000000020", + "local_authority_name": "Local Authority 20", + "x": 19, + "y": 0, + "average_household_income_change": -157.98504358729159, + "relative_household_income_change": 1e-12, + "population": 73478.18403793292 + }, + { + "local_authority_code": "E06000000021", + "local_authority_name": "Local Authority 21", + "x": 20, + "y": 0, + "average_household_income_change": 1070.4210969196931, + "relative_household_income_change": -3.5e-9, + "population": 98666.90369402937 + }, + { + "local_authority_code": "E06000000022", + "local_authority_name": "Local Authority 22", + "x": null, + "y": null, + "average_household_income_change": -1084.190750741544, + "relative_household_income_change": 0.836601005622632, + "population": 83210.36408380332 + }, + { + "local_authority_code": "E06000000023", + "local_authority_name": "Local Authority 23", + "x": 22, + "y": 0, + "average_household_income_change": 1042.6216477340079, + "relative_household_income_change": -3.5e-9, + "population": 82189.76985415467 + }, + { + "local_authority_code": "E06000000024", + "local_authority_name": "Local Authority 24", + "x": 23, + "y": 0, + "average_household_income_change": -356.59948313574176, + "relative_household_income_change": 0.0, + "population": 74556.76041445452 + }, + { + "local_authority_code": "E06000000025", + "local_authority_name": "Local Authority 25", + "x": 24, + "y": 0, + "average_household_income_change": 255.29861692466403, + "relative_household_income_change": 0.7232325273104853, + "population": 127857.36052385914 + }, + { + "local_authority_code": "E06000000026", + "local_authority_name": "Local Authority 26", + "x": 25, + "y": 0, + "average_household_income_change": -561.9967800290389, + "relative_household_income_change": 1e-12, + "population": 106980.91731680604 + }, + { + "local_authority_code": "E06000000027", + "local_authority_name": "Local Authority 27", + "x": 26, + "y": 0, + "average_household_income_change": -109.1144097657053, + "relative_household_income_change": 1e-12, + "population": 109578.0521407703 + }, + { + "local_authority_code": "E06000000028", + "local_authority_name": "Local Authority 28", + "x": 27, + "y": 0, + "average_household_income_change": -1367.295021366755, + "relative_household_income_change": 1e-12, + "population": 58898.86324268766 + }, + { + "local_authority_code": "E06000000029", + "local_authority_name": "Local Authority 29", + "x": null, + "y": null, + "average_household_income_change": 570.918963320222, + "relative_household_income_change": 0.0, + "population": 125338.41108172822 + }, + { + "local_authority_code": "E06000000030", + "local_authority_name": "Local Authority 30", + "x": 29, + "y": 0, + "average_household_income_change": -274.2402568191693, + "relative_household_income_change": -3.5e-9, + "population": 64611.39147086428 + }, + { + "local_authority_code": "E06000000031", + "local_authority_name": "Local Authority 31", + "x": 30, + "y": 0, + "average_household_income_change": 723.445821990056, + "relative_household_income_change": -0.0, + "population": 110356.63768942235 + }, + { + "local_authority_code": "E06000000032", + "local_authority_name": "Local Authority 32", + "x": 0, + "y": 1, + "average_household_income_change": -528.8110657531159, + "relative_household_income_change": -3.5e-9, + "population": 105587.30047688412 + }, + { + "local_authority_code": "E06000000033", + "local_authority_name": "Local Authority 33", + "x": 1, + "y": 1, + "average_household_income_change": -1899.0443795165293, + "relative_household_income_change": 1e-12, + "population": 52173.26085032475 + }, + { + "local_authority_code": "E06000000034", + "local_authority_name": "Local Authority 34", + "x": 2, + "y": 1, + "average_household_income_change": -948.6511027706763, + "relative_household_income_change": 0.861974368839776, + "population": 108831.07934043322 + }, + { + "local_authority_code": "E06000000035", + "local_authority_name": "Local Authority 35", + "x": 3, + "y": 1, + "average_household_income_change": 596.6157268316401, + "relative_household_income_change": 0.0, + "population": 102116.27736033645 + }, + { + "local_authority_code": "E06000000036", + "local_authority_name": "Local Authority 36", + "x": null, + "y": null, + "average_household_income_change": 207.93735297858885, + "relative_household_income_change": 0.0, + "population": 116564.66422229337 + }, + { + "local_authority_code": "E06000000037", + "local_authority_name": "Local Authority 37", + "x": 5, + "y": 1, + "average_household_income_change": -99.3150549349906, + "relative_household_income_change": 1e-12, + "population": 101593.40434133449 + }, + { + "local_authority_code": "E06000000038", + "local_authority_name": "Local Authority 38", + "x": 6, + "y": 1, + "average_household_income_change": -386.4531583157946, + "relative_household_income_change": 0.0, + "population": 141472.1770422101 + }, + { + "local_authority_code": "E06000000039", + "local_authority_name": "Local Authority 39", + "x": 7, + "y": 1, + "average_household_income_change": 330.0627465742383, + "relative_household_income_change": 0.05215779997016934, + "population": 141852.21151584186 + }, + { + "local_authority_code": "E06000000040", + "local_authority_name": "Local Authority 40", + "x": 8, + "y": 1, + "average_household_income_change": -1133.1155149702777, + "relative_household_income_change": 0.0, + "population": 90600.31790542022 + }, + { + "local_authority_code": "E06000000041", + "local_authority_name": "Local Authority 41", + "x": 9, + "y": 1, + "average_household_income_change": -839.5358563959885, + "relative_household_income_change": 0.13584478762195062, + "population": 88141.137590001 + }, + { + "local_authority_code": "E06000000042", + "local_authority_name": "Local Authority 42", + "x": 10, + "y": 1, + "average_household_income_change": -1165.481582988086, + "relative_household_income_change": -3.5e-9, + "population": 73433.4816768881 + }, + { + "local_authority_code": "E06000000043", + "local_authority_name": "Local Authority 43", + "x": null, + "y": null, + "average_household_income_change": -373.5052701686232, + "relative_household_income_change": -3.5e-9, + "population": 79419.4013814455 + }, + { + "local_authority_code": "E06000000044", + "local_authority_name": "Local Authority 44", + "x": 12, + "y": 1, + "average_household_income_change": -1160.1562975452566, + "relative_household_income_change": -3.5e-9, + "population": 85899.06902370857 + }, + { + "local_authority_code": "E06000000045", + "local_authority_name": "Local Authority 45", + "x": 13, + "y": 1, + "average_household_income_change": 758.790098109745, + "relative_household_income_change": -3.5e-9, + "population": 63063.28444224117 + }, + { + "local_authority_code": "E06000000046", + "local_authority_name": "Local Authority 46", + "x": 14, + "y": 1, + "average_household_income_change": -1029.720675973574, + "relative_household_income_change": -0.0, + "population": 104506.66479956872 + }, + { + "local_authority_code": "E06000000047", + "local_authority_name": "Local Authority 47", + "x": 15, + "y": 1, + "average_household_income_change": -516.1419060292656, + "relative_household_income_change": -3.5e-9, + "population": 72118.73988243361 + }, + { + "local_authority_code": "E06000000048", + "local_authority_name": "Local Authority 48", + "x": 16, + "y": 1, + "average_household_income_change": -1305.9071114052686, + "relative_household_income_change": 0.0, + "population": 83866.0479801991 + }, + { + "local_authority_code": "E06000000049", + "local_authority_name": "Local Authority 49", + "x": 17, + "y": 1, + "average_household_income_change": 336.04631080583937, + "relative_household_income_change": -3.5e-9, + "population": 143454.1556254162 + }, + { + "local_authority_code": "E06000000050", + "local_authority_name": "Local Authority 50", + "x": null, + "y": null, + "average_household_income_change": -1720.7042384504155, + "relative_household_income_change": 0.0, + "population": 60713.73636327979 + }, + { + "local_authority_code": "E06000000051", + "local_authority_name": "Local Authority 51", + "x": 19, + "y": 1, + "average_household_income_change": 1434.7514320345974, + "relative_household_income_change": -3.5e-9, + "population": 81076.07543203316 + }, + { + "local_authority_code": "E06000000052", + "local_authority_name": "Local Authority 52", + "x": 20, + "y": 1, + "average_household_income_change": 367.94847637922425, + "relative_household_income_change": 0.0, + "population": 57169.51240739431 + }, + { + "local_authority_code": "E06000000053", + "local_authority_name": "Local Authority 53", + "x": 21, + "y": 1, + "average_household_income_change": -134.27102983545325, + "relative_household_income_change": -3.5e-9, + "population": 112260.18240877741 + }, + { + "local_authority_code": "E06000000054", + "local_authority_name": "Local Authority 54", + "x": 22, + "y": 1, + "average_household_income_change": -1307.3113454330883, + "relative_household_income_change": 1e-12, + "population": 129712.91555041495 + }, + { + "local_authority_code": "E06000000055", + "local_authority_name": "Local Authority 55", + "x": 23, + "y": 1, + "average_household_income_change": 1698.0689767657648, + "relative_household_income_change": 0.0, + "population": 56651.85897650984 + }, + { + "local_authority_code": "E06000000056", + "local_authority_name": "Local Authority 56", + "x": 24, + "y": 1, + "average_household_income_change": -684.5442861532538, + "relative_household_income_change": 0.0, + "population": 51961.63664619178 + }, + { + "local_authority_code": "E06000000057", + "local_authority_name": "Local Authority 57", + "x": null, + "y": null, + "average_household_income_change": -978.7187202471532, + "relative_household_income_change": -3.5e-9, + "population": 97885.13067633384 + }, + { + "local_authority_code": "E06000000058", + "local_authority_name": "Local Authority 58", + "x": 26, + "y": 1, + "average_household_income_change": 1770.8198408401636, + "relative_household_income_change": 0.4022130639378835, + "population": 116710.80210949371 + }, + { + "local_authority_code": "E06000000059", + "local_authority_name": "Local Authority 59", + "x": 27, + "y": 1, + "average_household_income_change": -227.21024178206807, + "relative_household_income_change": 0.0, + "population": 148868.47538700586 + }, + { + "local_authority_code": "E06000000060", + "local_authority_name": "Local Authority 60", + "x": 28, + "y": 1, + "average_household_income_change": 406.1970125350696, + "relative_household_income_change": -0.0, + "population": 69260.03618381429 + }, + { + "local_authority_code": "E06000000061", + "local_authority_name": "Local Authority 61", + "x": 29, + "y": 1, + "average_household_income_change": -74.91285483834443, + "relative_household_income_change": 1e-12, + "population": 142780.21598839073 + }, + { + "local_authority_code": "E06000000062", + "local_authority_name": "Local Authority 62", + "x": 30, + "y": 1, + "average_household_income_change": -1339.6500922702558, + "relative_household_income_change": -0.0, + "population": 99488.94447212561 + }, + { + "local_authority_code": "E06000000063", + "local_authority_name": "Local Authority 63", + "x": 0, + "y": 2, + "average_household_income_change": -1298.0181141563762, + "relative_household_income_change": -3.5e-9, + "population": 55030.787258256365 + }, + { + "local_authority_code": "E06000000064", + "local_authority_name": "Local Authority 64", + "x": null, + "y": null, + "average_household_income_change": -54.75236674179662, + "relative_household_income_change": -0.0, + "population": 138632.11137234024 + }, + { + "local_authority_code": "E06000000065", + "local_authority_name": "Local Authority 65", + "x": 2, + "y": 2, + "average_household_income_change": -1845.58137993721, + "relative_household_income_change": -3.5e-9, + "population": 60267.99379230523 + }, + { + "local_authority_code": "E06000000066", + "local_authority_name": "Local Authority 66", + "x": 3, + "y": 2, + "average_household_income_change": -524.38253465656, + "relative_household_income_change": -0.0, + "population": 102857.31969449116 + }, + { + "local_authority_code": "E06000000067", + "local_authority_name": "Local Authority 67", + "x": 4, + "y": 2, + "average_household_income_change": 1417.0468691939302, + "relative_household_income_change": 1e-12, + "population": 60692.56747606022 + }, + { + "local_authority_code": "E06000000068", + "local_authority_name": "Local Authority 68", + "x": 5, + "y": 2, + "average_household_income_change": -669.6944521175205, + "relative_household_income_change": 1e-12, + "population": 62205.01230755512 + }, + { + "local_authority_code": "E06000000069", + "local_authority_name": "Local Authority 69", + "x": 6, + "y": 2, + "average_household_income_change": -697.2904833598284, + "relative_household_income_change": 0.32579234099818233, + "population": 50703.25977836838 + }, + { + "local_authority_code": "E06000000070", + "local_authority_name": "Local Authority 70", + "x": 7, + "y": 2, + "average_household_income_change": 1648.9893529969822, + "relative_household_income_change": 1e-12, + "population": 111110.29655746072 + }, + { + "local_authority_code": "E06000000071", + "local_authority_name": "Local Authority 71", + "x": null, + "y": null, + "average_household_income_change": 1655.3433374587626, + "relative_household_income_change": 1e-12, + "population": 107139.20307198947 + }, + { + "local_authority_code": "E06000000072", + "local_authority_name": "Local Authority 72", + "x": 9, + "y": 2, + "average_household_income_change": 1155.7966124245381, + "relative_household_income_change": -3.5e-9, + "population": 77257.50624124409 + }, + { + "local_authority_code": "E06000000073", + "local_authority_name": "Local Authority 73", + "x": 10, + "y": 2, + "average_household_income_change": -739.4421680729821, + "relative_household_income_change": 1e-12, + "population": 147255.8211176183 + }, + { + "local_authority_code": "E06000000074", + "local_authority_name": "Local Authority 74", + "x": 11, + "y": 2, + "average_household_income_change": -1876.352256872487, + "relative_household_income_change": -0.0, + "population": 112467.48919408373 + }, + { + "local_authority_code": "E06000000075", + "local_authority_name": "Local Authority 75", + "x": 12, + "y": 2, + "average_household_income_change": -637.7726449765091, + "relative_household_income_change": -0.0, + "population": 143837.46577293432 + }, + { + "local_authority_code": "E06000000076", + "local_authority_name": "Local Authority 76", + "x": 13, + "y": 2, + "average_household_income_change": 42.97080220967642, + "relative_household_income_change": -0.0, + "population": 136007.1936987238 + }, + { + "local_authority_code": "E06000000077", + "local_authority_name": "Local Authority 77", + "x": 14, + "y": 2, + "average_household_income_change": 246.10490045053135, + "relative_household_income_change": -3.5e-9, + "population": 112922.3491300438 + }, + { + "local_authority_code": "E06000000078", + "local_authority_name": "Local Authority 78", + "x": null, + "y": null, + "average_household_income_change": 1391.1407839376343, + "relative_household_income_change": -0.0, + "population": 97785.16806463109 + }, + { + "local_authority_code": "E06000000079", + "local_authority_name": "Local Authority 79", + "x": 16, + "y": 2, + "average_household_income_change": -1577.6611573462892, + "relative_household_income_change": 0.8397807712331508, + "population": 61633.32002388823 + }, + { + "local_authority_code": "E06000000080", + "local_authority_name": "Local Authority 80", + "x": 17, + "y": 2, + "average_household_income_change": 1621.9689662911376, + "relative_household_income_change": 0.44786982566411027, + "population": 141121.86104609378 + }, + { + "local_authority_code": "E06000000081", + "local_authority_name": "Local Authority 81", + "x": 18, + "y": 2, + "average_household_income_change": 1843.023545552603, + "relative_household_income_change": 1e-12, + "population": 100289.83457812988 + }, + { + "local_authority_code": "E06000000082", + "local_authority_name": "Local Authority 82", + "x": 19, + "y": 2, + "average_household_income_change": -1574.802477133047, + "relative_household_income_change": -0.0, + "population": 107128.20337032386 + }, + { + "local_authority_code": "E06000000083", + "local_authority_name": "Local Authority 83", + "x": 20, + "y": 2, + "average_household_income_change": 1700.2682547782751, + "relative_household_income_change": 0.8286985245589028, + "population": 130406.17866850618 + }, + { + "local_authority_code": "E06000000084", + "local_authority_name": "Local Authority 84", + "x": 21, + "y": 2, + "average_household_income_change": -109.59877514069305, + "relative_household_income_change": 1e-12, + "population": 143330.49244997563 + }, + { + "local_authority_code": "E06000000085", + "local_authority_name": "Local Authority 85", + "x": null, + "y": null, + "average_household_income_change": 634.4029721965949, + "relative_household_income_change": -3.5e-9, + "population": 84288.84699659675 + }, + { + "local_authority_code": "E06000000086", + "local_authority_name": "Local Authority 86", + "x": 23, + "y": 2, + "average_household_income_change": 298.3073319458458, + "relative_household_income_change": -0.0, + "population": 112072.35527186739 + }, + { + "local_authority_code": "E06000000087", + "local_authority_name": "Local Authority 87", + "x": 24, + "y": 2, + "average_household_income_change": 1162.613580175742, + "relative_household_income_change": 0.8735826901840349, + "population": 53334.41826254266 + }, + { + "local_authority_code": "E06000000088", + "local_authority_name": "Local Authority 88", + "x": 25, + "y": 2, + "average_household_income_change": 1269.5038134454041, + "relative_household_income_change": -0.0, + "population": 146967.75846424367 + }, + { + "local_authority_code": "E06000000089", + "local_authority_name": "Local Authority 89", + "x": 26, + "y": 2, + "average_household_income_change": 941.4584916929489, + "relative_household_income_change": 0.0, + "population": 99442.99104208047 + }, + { + "local_authority_code": "E06000000090", + "local_authority_name": "Local Authority 90", + "x": 27, + "y": 2, + "average_household_income_change": -124.6417436565066, + "relative_household_income_change": 0.0, + "population": 80384.06129506999 + }, + { + "local_authority_code": "E06000000091", + "local_authority_name": "Local Authority 91", + "x": 28, + "y": 2, + "average_household_income_change": -131.22312675529324, + "relative_household_income_change": 0.0, + "population": 95193.73712188863 + }, + { + "local_authority_code": "E06000000092", + "local_authority_name": "Local Authority 92", + "x": null, + "y": null, + "average_household_income_change": 1625.5843992648, + "relative_household_income_change": -0.0, + "population": 141357.51829077472 + }, + { + "local_authority_code": "E06000000093", + "local_authority_name": "Local Authority 93", + "x": 30, + "y": 2, + "average_household_income_change": 424.8513763312842, + "relative_household_income_change": -3.5e-9, + "population": 119300.97621805023 + }, + { + "local_authority_code": "E06000000094", + "local_authority_name": "Local Authority 94", + "x": 0, + "y": 3, + "average_household_income_change": 1268.131101085673, + "relative_household_income_change": -0.0, + "population": 149013.66364449463 + }, + { + "local_authority_code": "E06000000095", + "local_authority_name": "Local Authority 95", + "x": 1, + "y": 3, + "average_household_income_change": -1530.9993636045124, + "relative_household_income_change": 0.0, + "population": 116664.05867270652 + }, + { + "local_authority_code": "E06000000096", + "local_authority_name": "Local Authority 96", + "x": 2, + "y": 3, + "average_household_income_change": -1949.1910457980314, + "relative_household_income_change": 0.0, + "population": 112806.71261334998 + }, + { + "local_authority_code": "E06000000097", + "local_authority_name": "Local Authority 97", + "x": 3, + "y": 3, + "average_household_income_change": 540.3517666893554, + "relative_household_income_change": -0.0, + "population": 59479.33774122507 + }, + { + "local_authority_code": "E06000000098", + "local_authority_name": "Local Authority 98", + "x": 4, + "y": 3, + "average_household_income_change": 91.25874221760988, + "relative_household_income_change": 0.0, + "population": 66358.81394243162 + }, + { + "local_authority_code": "E06000000099", + "local_authority_name": "Local Authority 99", + "x": null, + "y": null, + "average_household_income_change": 259.0652403777276, + "relative_household_income_change": 0.2340381841126019, + "population": 75494.00711254979 + }, + { + "local_authority_code": "E06000000100", + "local_authority_name": "Local Authority 100", + "x": 6, + "y": 3, + "average_household_income_change": -1245.9852723180682, + "relative_household_income_change": -0.0, + "population": 92066.49441666392 + }, + { + "local_authority_code": "E06000000101", + "local_authority_name": "Local Authority 101", + "x": 7, + "y": 3, + "average_household_income_change": -1579.4667973681244, + "relative_household_income_change": -3.5e-9, + "population": 100521.50805462364 + }, + { + "local_authority_code": "E06000000102", + "local_authority_name": "Local Authority 102", + "x": 8, + "y": 3, + "average_household_income_change": -384.01290422337195, + "relative_household_income_change": 1e-12, + "population": 114009.55195049211 + }, + { + "local_authority_code": "E06000000103", + "local_authority_name": "Local Authority 103", + "x": 9, + "y": 3, + "average_household_income_change": 126.69515084896732, + "relative_household_income_change": 1e-12, + "population": 127439.82030562818 + }, + { + "local_authority_code": "E06000000104", + "local_authority_name": "Local Authority 104", + "x": 10, + "y": 3, + "average_household_income_change": -1301.7351067368743, + "relative_household_income_change": -0.0, + "population": 53563.63516660295 + }, + { + "local_authority_code": "E06000000105", + "local_authority_name": "Local Authority 105", + "x": 11, + "y": 3, + "average_household_income_change": -1718.9549225276987, + "relative_household_income_change": 0.0, + "population": 82068.41317409082 + }, + { + "local_authority_code": "E06000000106", + "local_authority_name": "Local Authority 106", + "x": null, + "y": null, + "average_household_income_change": -1730.7507441245823, + "relative_household_income_change": 1e-12, + "population": 119226.4080348876 + }, + { + "local_authority_code": "E06000000107", + "local_authority_name": "Local Authority 107", + "x": 13, + "y": 3, + "average_household_income_change": 234.4235660991254, + "relative_household_income_change": 1e-12, + "population": 140046.32145941973 + }, + { + "local_authority_code": "E06000000108", + "local_authority_name": "Local Authority 108", + "x": 14, + "y": 3, + "average_household_income_change": 575.5706432427354, + "relative_household_income_change": 0.3550402964498325, + "population": 106380.05723099297 + }, + { + "local_authority_code": "E06000000109", + "local_authority_name": "Local Authority 109", + "x": 15, + "y": 3, + "average_household_income_change": -892.4872885317292, + "relative_household_income_change": -3.5e-9, + "population": 53076.74660716412 + }, + { + "local_authority_code": "E06000000110", + "local_authority_name": "Local Authority 110", + "x": 16, + "y": 3, + "average_household_income_change": -939.2971206471809, + "relative_household_income_change": -3.5e-9, + "population": 104433.28349564088 + }, + { + "local_authority_code": "E06000000111", + "local_authority_name": "Local Authority 111", + "x": 17, + "y": 3, + "average_household_income_change": 369.1730214135318, + "relative_household_income_change": 1e-12, + "population": 111068.85198051632 + }, + { + "local_authority_code": "E06000000112", + "local_authority_name": "Local Authority 112", + "x": 18, + "y": 3, + "average_household_income_change": -1980.1767990273622, + "relative_household_income_change": -3.5e-9, + "population": 149422.4455146322 + }, + { + "local_authority_code": "E06000000113", + "local_authority_name": "Local Authority 113", + "x": null, + "y": null, + "average_household_income_change": -1685.3969016955593, + "relative_household_income_change": 0.6177421413249037, + "population": 73272.14262762116 + }, + { + "local_authority_code": "E06000000114", + "local_authority_name": "Local Authority 114", + "x": 20, + "y": 3, + "average_household_income_change": 857.8109681964099, + "relative_household_income_change": 0.356428163358969, + "population": 51232.78052923555 + }, + { + "local_authority_code": "E06000000115", + "local_authority_name": "Local Authority 115", + "x": 21, + "y": 3, + "average_household_income_change": 1950.5758188370517, + "relative_household_income_change": 1e-12, + "population": 76115.71228923349 + }, + { + "local_authority_code": "E06000000116", + "local_authority_name": "Local Authority 116", + "x": 22, + "y": 3, + "average_household_income_change": -1160.224065252241, + "relative_household_income_change": -0.0, + "population": 89923.61812913681 + }, + { + "local_authority_code": "E06000000117", + "local_authority_name": "Local Authority 117", + "x": 23, + "y": 3, + "average_household_income_change": -1003.0071129789468, + "relative_household_income_change": -0.0, + "population": 55023.62428393849 + }, + { + "local_authority_code": "E06000000118", + "local_authority_name": "Local Authority 118", + "x": 24, + "y": 3, + "average_household_income_change": -665.3379484000661, + "relative_household_income_change": -3.5e-9, + "population": 95601.70751376684 + }, + { + "local_authority_code": "E06000000119", + "local_authority_name": "Local Authority 119", + "x": 25, + "y": 3, + "average_household_income_change": -1166.075660949811, + "relative_household_income_change": -0.0, + "population": 103266.09447574029 + }, + { + "local_authority_code": "E06000000120", + "local_authority_name": "Local Authority 120", + "x": null, + "y": null, + "average_household_income_change": 1860.735460695461, + "relative_household_income_change": -0.0, + "population": 131775.97595322173 + }, + { + "local_authority_code": "E06000000121", + "local_authority_name": "Local Authority 121", + "x": 27, + "y": 3, + "average_household_income_change": 1580.0608324809184, + "relative_household_income_change": 0.0, + "population": 78812.01228506769 + }, + { + "local_authority_code": "E06000000122", + "local_authority_name": "Local Authority 122", + "x": 28, + "y": 3, + "average_household_income_change": 1785.1647270269898, + "relative_household_income_change": 0.05415064813031445, + "population": 113159.23111465383 + }, + { + "local_authority_code": "E06000000123", + "local_authority_name": "Local Authority 123", + "x": 29, + "y": 3, + "average_household_income_change": 1534.2332923215959, + "relative_household_income_change": 0.0, + "population": 141819.93936978036 + }, + { + "local_authority_code": "E06000000124", + "local_authority_name": "Local Authority 124", + "x": 30, + "y": 3, + "average_household_income_change": 1572.181832031823, + "relative_household_income_change": 1e-12, + "population": 86350.96665343967 + }, + { + "local_authority_code": "E06000000125", + "local_authority_name": "Local Authority 125", + "x": 0, + "y": 4, + "average_household_income_change": -1605.8392299157967, + "relative_household_income_change": 0.6240767613094547, + "population": 144735.63572544855 + }, + { + "local_authority_code": "E06000000126", + "local_authority_name": "Local Authority 126", + "x": 1, + "y": 4, + "average_household_income_change": 200.19623482877958, + "relative_household_income_change": -3.5e-9, + "population": 51753.161581743465 + }, + { + "local_authority_code": "E06000000127", + "local_authority_name": "Local Authority 127", + "x": null, + "y": null, + "average_household_income_change": 1195.8534047937524, + "relative_household_income_change": 0.5409499896423211, + "population": 129532.97590453389 + }, + { + "local_authority_code": "E06000000128", + "local_authority_name": "Local Authority 128", + "x": 3, + "y": 4, + "average_household_income_change": -1637.0444378301024, + "relative_household_income_change": -0.0, + "population": 53457.8999979193 + }, + { + "local_authority_code": "E06000000129", + "local_authority_name": "Local Authority 129", + "x": 4, + "y": 4, + "average_household_income_change": 885.2623772470519, + "relative_household_income_change": -3.5e-9, + "population": 123118.85395640823 + }, + { + "local_authority_code": "E06000000130", + "local_authority_name": "Local Authority 130", + "x": 5, + "y": 4, + "average_household_income_change": 1013.1201826824805, + "relative_household_income_change": -3.5e-9, + "population": 144611.15210387774 + }, + { + "local_authority_code": "E06000000131", + "local_authority_name": "Local Authority 131", + "x": 6, + "y": 4, + "average_household_income_change": 927.1284242260824, + "relative_household_income_change": -0.0, + "population": 113506.86780643024 + }, + { + "local_authority_code": "E06000000132", + "local_authority_name": "Local Authority 132", + "x": 7, + "y": 4, + "average_household_income_change": 993.180340538264, + "relative_household_income_change": -3.5e-9, + "population": 147590.3661673587 + }, + { + "local_authority_code": "E06000000133", + "local_authority_name": "Local Authority 133", + "x": 8, + "y": 4, + "average_household_income_change": -1761.3207876794465, + "relative_household_income_change": 0.8210333940864938, + "population": 64270.32513059139 + }, + { + "local_authority_code": "E06000000134", + "local_authority_name": "Local Authority 134", + "x": null, + "y": null, + "average_household_income_change": -1677.9391875677602, + "relative_household_income_change": 1e-12, + "population": 102291.43195281904 + }, + { + "local_authority_code": "E06000000135", + "local_authority_name": "Local Authority 135", + "x": 10, + "y": 4, + "average_household_income_change": -528.5457385095484, + "relative_household_income_change": 0.23264565175973118, + "population": 78181.4017919634 + }, + { + "local_authority_code": "E06000000136", + "local_authority_name": "Local Authority 136", + "x": 11, + "y": 4, + "average_household_income_change": -1809.900053696826, + "relative_household_income_change": 0.954552658406543, + "population": 103838.26790233378 + }, + { + "local_authority_code": "E06000000137", + "local_authority_name": "Local Authority 137", + "x": 12, + "y": 4, + "average_household_income_change": 175.59819725277003, + "relative_household_income_change": 1e-12, + "population": 114961.59077385967 + }, + { + "local_authority_code": "E06000000138", + "local_authority_name": "Local Authority 138", + "x": 13, + "y": 4, + "average_household_income_change": -1694.1470757332215, + "relative_household_income_change": 0.07908142310991939, + "population": 66771.1061948385 + }, + { + "local_authority_code": "E06000000139", + "local_authority_name": "Local Authority 139", + "x": 14, + "y": 4, + "average_household_income_change": 1812.1023900051255, + "relative_household_income_change": -3.5e-9, + "population": 107198.30332877951 + }, + { + "local_authority_code": "E06000000140", + "local_authority_name": "Local Authority 140", + "x": 15, + "y": 4, + "average_household_income_change": -1538.359829800179, + "relative_household_income_change": 0.03787036997739923, + "population": 116807.61139000079 + }, + { + "local_authority_code": "E06000000141", + "local_authority_name": "Local Authority 141", + "x": null, + "y": null, + "average_household_income_change": 1289.5074707439016, + "relative_household_income_change": 0.3477828575349734, + "population": 55879.49043451492 + }, + { + "local_authority_code": "E06000000142", + "local_authority_name": "Local Authority 142", + "x": 17, + "y": 4, + "average_household_income_change": -513.751954512872, + "relative_household_income_change": -0.0, + "population": 62397.385318745444 + }, + { + "local_authority_code": "E06000000143", + "local_authority_name": "Local Authority 143", + "x": 18, + "y": 4, + "average_household_income_change": -1207.0547909465174, + "relative_household_income_change": 0.4321149063234381, + "population": 132599.14340379782 + }, + { + "local_authority_code": "E06000000144", + "local_authority_name": "Local Authority 144", + "x": 19, + "y": 4, + "average_household_income_change": -1167.7105392959963, + "relative_household_income_change": 0.11568502973031569, + "population": 129009.39626547943 + }, + { + "local_authority_code": "E06000000145", + "local_authority_name": "Local Authority 145", + "x": 20, + "y": 4, + "average_household_income_change": -617.4303141086494, + "relative_household_income_change": 0.0, + "population": 135302.34238248182 + }, + { + "local_authority_code": "E06000000146", + "local_authority_name": "Local Authority 146", + "x": 21, + "y": 4, + "average_household_income_change": -1312.877126927524, + "relative_household_income_change": 0.26760164671532716, + "population": 108770.59369107803 + }, + { + "local_authority_code": "E06000000147", + "local_authority_name": "Local Authority 147", + "x": 22, + "y": 4, + "average_household_income_change": -1338.488585593179, + "relative_household_income_change": 1e-12, + "population": 128892.88998607552 + }, + { + "local_authority_code": "E06000000148", + "local_authority_name": "Local Authority 148", + "x": null, + "y": null, + "average_household_income_change": 545.2771172295146, + "relative_household_income_change": -0.0, + "population": 87531.96012899585 + }, + { + "local_authority_code": "E06000000149", + "local_authority_name": "Local Authority 149", + "x": 24, + "y": 4, + "average_household_income_change": 1213.1704992739687, + "relative_household_income_change": -3.5e-9, + "population": 60503.947543959046 + }, + { + "local_authority_code": "E06000000150", + "local_authority_name": "Local Authority 150", + "x": 25, + "y": 4, + "average_household_income_change": -219.29221357147676, + "relative_household_income_change": -3.5e-9, + "population": 64616.75513116325 + }, + { + "local_authority_code": "E06000000151", + "local_authority_name": "Local Authority 151", + "x": 26, + "y": 4, + "average_household_income_change": 1916.5046242970254, + "relative_household_income_change": 1e-12, + "population": 79823.49679126855 + }, + { + "local_authority_code": "E06000000152", + "local_authority_name": "Local Authority 152", + "x": 27, + "y": 4, + "average_household_income_change": 725.2624870496547, + "relative_household_income_change": 0.844017537795965, + "population": 62800.32153726286 + }, + { + "local_authority_code": "E06000000153", + "local_authority_name": "Local Authority 153", + "x": 28, + "y": 4, + "average_household_income_change": 1298.767112545541, + "relative_household_income_change": -3.5e-9, + "population": 66498.84726634368 + }, + { + "local_authority_code": "E06000000154", + "local_authority_name": "Local Authority 154", + "x": 29, + "y": 4, + "average_household_income_change": -1938.804885839403, + "relative_household_income_change": -0.0, + "population": 61460.28946534437 + }, + { + "local_authority_code": "E06000000155", + "local_authority_name": "Local Authority 155", + "x": null, + "y": null, + "average_household_income_change": 1136.131498777778, + "relative_household_income_change": -0.0, + "population": 67484.6362362209 + }, + { + "local_authority_code": "E06000000156", + "local_authority_name": "Local Authority 156", + "x": 0, + "y": 5, + "average_household_income_change": -19.944300086543354, + "relative_household_income_change": -3.5e-9, + "population": 57647.27599030901 + }, + { + "local_authority_code": "E06000000157", + "local_authority_name": "Local Authority 157", + "x": 1, + "y": 5, + "average_household_income_change": -1404.5693292455107, + "relative_household_income_change": -0.0, + "population": 140489.2540612634 + }, + { + "local_authority_code": "E06000000158", + "local_authority_name": "Local Authority 158", + "x": 2, + "y": 5, + "average_household_income_change": 1585.0801748542729, + "relative_household_income_change": -3.5e-9, + "population": 96200.97948146173 + }, + { + "local_authority_code": "E06000000159", + "local_authority_name": "Local Authority 159", + "x": 3, + "y": 5, + "average_household_income_change": 1168.9720491958878, + "relative_household_income_change": 0.8188434437327052, + "population": 54464.32197461528 + }, + { + "local_authority_code": "E06000000160", + "local_authority_name": "Local Authority 160", + "x": 4, + "y": 5, + "average_household_income_change": 1514.9856804708609, + "relative_household_income_change": -3.5e-9, + "population": 50183.4497151524 + }, + { + "local_authority_code": "E06000000161", + "local_authority_name": "Local Authority 161", + "x": 5, + "y": 5, + "average_household_income_change": -1331.1166293341512, + "relative_household_income_change": -3.5e-9, + "population": 58108.164802832755 + }, + { + "local_authority_code": "E06000000162", + "local_authority_name": "Local Authority 162", + "x": null, + "y": null, + "average_household_income_change": -476.03320757418555, + "relative_household_income_change": 0.8733176798470372, + "population": 102673.4921284775 + }, + { + "local_authority_code": "E06000000163", + "local_authority_name": "Local Authority 163", + "x": 7, + "y": 5, + "average_household_income_change": 20.200059195911724, + "relative_household_income_change": 0.4010141399599696, + "population": 148128.53103472033 + }, + { + "local_authority_code": "E06000000164", + "local_authority_name": "Local Authority 164", + "x": 8, + "y": 5, + "average_household_income_change": 1943.317850704771, + "relative_household_income_change": 0.2938326528671249, + "population": 53467.82852088674 + }, + { + "local_authority_code": "E06000000165", + "local_authority_name": "Local Authority 165", + "x": 9, + "y": 5, + "average_household_income_change": -1108.9593653220206, + "relative_household_income_change": -3.5e-9, + "population": 71870.87879550472 + }, + { + "local_authority_code": "E06000000166", + "local_authority_name": "Local Authority 166", + "x": 10, + "y": 5, + "average_household_income_change": 1417.6707836259984, + "relative_household_income_change": -3.5e-9, + "population": 85702.49386785214 + }, + { + "local_authority_code": "E06000000167", + "local_authority_name": "Local Authority 167", + "x": 11, + "y": 5, + "average_household_income_change": -1191.6164426791443, + "relative_household_income_change": -0.0, + "population": 92120.03448182667 + }, + { + "local_authority_code": "E06000000168", + "local_authority_name": "Local Authority 168", + "x": 12, + "y": 5, + "average_household_income_change": -1521.2609606051815, + "relative_household_income_change": 1e-12, + "population": 96889.65526830831 + }, + { + "local_authority_code": "E06000000169", + "local_authority_name": "Local Authority 169", + "x": null, + "y": null, + "average_household_income_change": -1075.9555412420489, + "relative_household_income_change": -3.5e-9, + "population": 64098.17611631555 + }, + { + "local_authority_code": "E06000000170", + "local_authority_name": "Local Authority 170", + "x": 14, + "y": 5, + "average_household_income_change": -1537.4856380387891, + "relative_household_income_change": 0.0, + "population": 51758.74338339896 + }, + { + "local_authority_code": "E06000000171", + "local_authority_name": "Local Authority 171", + "x": 15, + "y": 5, + "average_household_income_change": 1371.007153530756, + "relative_household_income_change": 0.5859568395067843, + "population": 70540.06204219378 + }, + { + "local_authority_code": "E06000000172", + "local_authority_name": "Local Authority 172", + "x": 16, + "y": 5, + "average_household_income_change": -1906.5786914495363, + "relative_household_income_change": -3.5e-9, + "population": 101829.17278456208 + }, + { + "local_authority_code": "E06000000173", + "local_authority_name": "Local Authority 173", + "x": 17, + "y": 5, + "average_household_income_change": 708.8092408487393, + "relative_household_income_change": -3.5e-9, + "population": 139656.85560940191 + }, + { + "local_authority_code": "E06000000174", + "local_authority_name": "Local Authority 174", + "x": 18, + "y": 5, + "average_household_income_change": -272.20699932632454, + "relative_household_income_change": -3.5e-9, + "population": 54984.043048109976 + }, + { + "local_authority_code": "E06000000175", + "local_authority_name": "Local Authority 175", + "x": 19, + "y": 5, + "average_household_income_change": -1807.2069435793342, + "relative_household_income_change": -3.5e-9, + "population": 66932.47835188187 + }, + { + "local_authority_code": "E06000000176", + "local_authority_name": "Local Authority 176", + "x": null, + "y": null, + "average_household_income_change": 925.4429578341852, + "relative_household_income_change": 1e-12, + "population": 116852.65238026805 + }, + { + "local_authority_code": "E06000000177", + "local_authority_name": "Local Authority 177", + "x": 21, + "y": 5, + "average_household_income_change": -561.9776294311647, + "relative_household_income_change": -3.5e-9, + "population": 101182.57511547924 + }, + { + "local_authority_code": "E06000000178", + "local_authority_name": "Local Authority 178", + "x": 22, + "y": 5, + "average_household_income_change": -107.53985599744986, + "relative_household_income_change": 0.5851294909850242, + "population": 126404.4489031002 + }, + { + "local_authority_code": "E06000000179", + "local_authority_name": "Local Authority 179", + "x": 23, + "y": 5, + "average_household_income_change": -1841.4547555666206, + "relative_household_income_change": -0.0, + "population": 102623.3963837607 + }, + { + "local_authority_code": "E06000000180", + "local_authority_name": "Local Authority 180", + "x": 24, + "y": 5, + "average_household_income_change": -39.51880574533817, + "relative_household_income_change": 1e-12, + "population": 92619.37182461639 + }, + { + "local_authority_code": "E06000000181", + "local_authority_name": "Local Authority 181", + "x": 25, + "y": 5, + "average_household_income_change": -580.1224130447231, + "relative_household_income_change": 1e-12, + "population": 96884.8756462903 + }, + { + "local_authority_code": "E06000000182", + "local_authority_name": "Local Authority 182", + "x": 26, + "y": 5, + "average_household_income_change": 992.1120432616349, + "relative_household_income_change": 0.6451118196633416, + "population": 73957.67565706733 + }, + { + "local_authority_code": "E06000000183", + "local_authority_name": "Local Authority 183", + "x": null, + "y": null, + "average_household_income_change": -1732.1059858114807, + "relative_household_income_change": 1e-12, + "population": 65395.68845025848 + }, + { + "local_authority_code": "E06000000184", + "local_authority_name": "Local Authority 184", + "x": 28, + "y": 5, + "average_household_income_change": 710.5325151252426, + "relative_household_income_change": -3.5e-9, + "population": 109535.8179164822 + }, + { + "local_authority_code": "E06000000185", + "local_authority_name": "Local Authority 185", + "x": 29, + "y": 5, + "average_household_income_change": 1832.2854393017299, + "relative_household_income_change": -3.5e-9, + "population": 60669.82708708667 + }, + { + "local_authority_code": "E06000000186", + "local_authority_name": "Local Authority 186", + "x": 30, + "y": 5, + "average_household_income_change": 1819.569360396747, + "relative_household_income_change": 0.0, + "population": 79655.1474051923 + }, + { + "local_authority_code": "E06000000187", + "local_authority_name": "Local Authority 187", + "x": 0, + "y": 6, + "average_household_income_change": 408.54837765137654, + "relative_household_income_change": -0.0, + "population": 130590.61729258868 + }, + { + "local_authority_code": "E06000000188", + "local_authority_name": "Local Authority 188", + "x": 1, + "y": 6, + "average_household_income_change": -1643.6986591969683, + "relative_household_income_change": 1e-12, + "population": 59909.55130953969 + }, + { + "local_authority_code": "E06000000189", + "local_authority_name": "Local Authority 189", + "x": 2, + "y": 6, + "average_household_income_change": -1830.8478998657392, + "relative_household_income_change": 1e-12, + "population": 59489.20805628684 + }, + { + "local_authority_code": "E06000000190", + "local_authority_name": "Local Authority 190", + "x": null, + "y": null, + "average_household_income_change": -1905.7436084963838, + "relative_household_income_change": 0.8499679168235974, + "population": 52174.8103666158 + }, + { + "local_authority_code": "E06000000191", + "local_authority_name": "Local Authority 191", + "x": 4, + "y": 6, + "average_household_income_change": 757.2216364786452, + "relative_household_income_change": -3.5e-9, + "population": 129939.34349845978 + }, + { + "local_authority_code": "E06000000192", + "local_authority_name": "Local Authority 192", + "x": 5, + "y": 6, + "average_household_income_change": 1770.2853720417813, + "relative_household_income_change": -0.0, + "population": 87426.04381781204 + }, + { + "local_authority_code": "E06000000193", + "local_authority_name": "Local Authority 193", + "x": 6, + "y": 6, + "average_household_income_change": 348.9531695535768, + "relative_household_income_change": 0.0, + "population": 89746.21562269032 + }, + { + "local_authority_code": "E06000000194", + "local_authority_name": "Local Authority 194", + "x": 7, + "y": 6, + "average_household_income_change": -745.3033309829049, + "relative_household_income_change": 0.0, + "population": 138162.6468560306 + }, + { + "local_authority_code": "E06000000195", + "local_authority_name": "Local Authority 195", + "x": 8, + "y": 6, + "average_household_income_change": 1042.848222721133, + "relative_household_income_change": 0.06846816595354055, + "population": 68468.23825403182 + }, + { + "local_authority_code": "E06000000196", + "local_authority_name": "Local Authority 196", + "x": 9, + "y": 6, + "average_household_income_change": -1875.6841411500104, + "relative_household_income_change": -3.5e-9, + "population": 98053.93559753371 + }, + { + "local_authority_code": "E06000000197", + "local_authority_name": "Local Authority 197", + "x": null, + "y": null, + "average_household_income_change": 1680.4842438289293, + "relative_household_income_change": 1e-12, + "population": 110871.29037186065 + }, + { + "local_authority_code": "E06000000198", + "local_authority_name": "Local Authority 198", + "x": 11, + "y": 6, + "average_household_income_change": 355.7356241268361, + "relative_household_income_change": -3.5e-9, + "population": 61445.557257757966 + }, + { + "local_authority_code": "E06000000199", + "local_authority_name": "Local Authority 199", + "x": 12, + "y": 6, + "average_household_income_change": -1656.5910190820948, + "relative_household_income_change": 1e-12, + "population": 75125.88651361875 + }, + { + "local_authority_code": "E06000000200", + "local_authority_name": "Local Authority 200", + "x": 13, + "y": 6, + "average_household_income_change": 1519.5281972087762, + "relative_household_income_change": -3.5e-9, + "population": 132081.12620030972 + }, + { + "local_authority_code": "E06000000201", + "local_authority_name": "Local Authority 201", + "x": 14, + "y": 6, + "average_household_income_change": -892.9346315357928, + "relative_household_income_change": 0.0, + "population": 112829.92103707488 + }, + { + "local_authority_code": "E06000000202", + "local_authority_name": "Local Authority 202", + "x": 15, + "y": 6, + "average_household_income_change": 1880.715568929332, + "relative_household_income_change": -3.5e-9, + "population": 110874.8283873219 + }, + { + "local_authority_code": "E06000000203", + "local_authority_name": "Local Authority 203", + "x": 16, + "y": 6, + "average_household_income_change": 842.4145738218903, + "relative_household_income_change": -3.5e-9, + "population": 60412.61694731236 + }, + { + "local_authority_code": "E06000000204", + "local_authority_name": "Local Authority 204", + "x": null, + "y": null, + "average_household_income_change": -705.471357896287, + "relative_household_income_change": -3.5e-9, + "population": 138517.7948301562 + }, + { + "local_authority_code": "E06000000205", + "local_authority_name": "Local Authority 205", + "x": 18, + "y": 6, + "average_household_income_change": 1797.4377063971422, + "relative_household_income_change": 0.0, + "population": 55956.24889439437 + }, + { + "local_authority_code": "E06000000206", + "local_authority_name": "Local Authority 206", + "x": 19, + "y": 6, + "average_household_income_change": 864.4317426019547, + "relative_household_income_change": 0.033828876403854236, + "population": 57600.72894162411 + }, + { + "local_authority_code": "E06000000207", + "local_authority_name": "Local Authority 207", + "x": 20, + "y": 6, + "average_household_income_change": -55.65987599011679, + "relative_household_income_change": 1e-12, + "population": 108950.25500093267 + }, + { + "local_authority_code": "E06000000208", + "local_authority_name": "Local Authority 208", + "x": 21, + "y": 6, + "average_household_income_change": -1550.8351036272572, + "relative_household_income_change": -3.5e-9, + "population": 124542.21626147522 + }, + { + "local_authority_code": "E06000000209", + "local_authority_name": "Local Authority 209", + "x": 22, + "y": 6, + "average_household_income_change": -850.3270689942401, + "relative_household_income_change": 1e-12, + "population": 149228.6199633644 + }, + { + "local_authority_code": "E06000000210", + "local_authority_name": "Local Authority 210", + "x": 23, + "y": 6, + "average_household_income_change": -80.61036125681741, + "relative_household_income_change": -3.5e-9, + "population": 75852.58545389926 + }, + { + "local_authority_code": "E06000000211", + "local_authority_name": "Local Authority 211", + "x": null, + "y": null, + "average_household_income_change": 752.9782555919926, + "relative_household_income_change": 1e-12, + "population": 138696.91612606982 + }, + { + "local_authority_code": "E06000000212", + "local_authority_name": "Local Authority 212", + "x": 25, + "y": 6, + "average_household_income_change": 1335.5289377583035, + "relative_household_income_change": 1e-12, + "population": 69346.05149303882 + }, + { + "local_authority_code": "E06000000213", + "local_authority_name": "Local Authority 213", + "x": 26, + "y": 6, + "average_household_income_change": 1893.1738017177952, + "relative_household_income_change": 0.0, + "population": 132010.16436654507 + }, + { + "local_authority_code": "E06000000214", + "local_authority_name": "Local Authority 214", + "x": 27, + "y": 6, + "average_household_income_change": -569.6717516392021, + "relative_household_income_change": 0.7832180104424888, + "population": 95128.54328083304 + }, + { + "local_authority_code": "E06000000215", + "local_authority_name": "Local Authority 215", + "x": 28, + "y": 6, + "average_household_income_change": -1627.883853584315, + "relative_household_income_change": 0.0, + "population": 101153.74778774349 + }, + { + "local_authority_code": "E06000000216", + "local_authority_name": "Local Authority 216", + "x": 29, + "y": 6, + "average_household_income_change": -1322.8076449238276, + "relative_household_income_change": -3.5e-9, + "population": 56347.31430493599 + }, + { + "local_authority_code": "E06000000217", + "local_authority_name": "Local Authority 217", + "x": 30, + "y": 6, + "average_household_income_change": 1519.4125827216465, + "relative_household_income_change": -0.0, + "population": 141806.4685395045 + }, + { + "local_authority_code": "E06000000218", + "local_authority_name": "Local Authority 218", + "x": null, + "y": null, + "average_household_income_change": 1509.8863685346876, + "relative_household_income_change": 0.4518054828480579, + "population": 111379.4220157825 + }, + { + "local_authority_code": "E06000000219", + "local_authority_name": "Local Authority 219", + "x": 1, + "y": 7, + "average_household_income_change": 858.0575413750009, + "relative_household_income_change": 0.6515707052700975, + "population": 101392.46293323096 + }, + { + "local_authority_code": "E06000000220", + "local_authority_name": "Local Authority 220", + "x": 2, + "y": 7, + "average_household_income_change": 1539.063867606706, + "relative_household_income_change": 0.08332081692084092, + "population": 106227.72748735608 + }, + { + "local_authority_code": "E06000000221", + "local_authority_name": "Local Authority 221", + "x": 3, + "y": 7, + "average_household_income_change": 1125.9579822870646, + "relative_household_income_change": 1e-12, + "population": 79072.8218332542 + }, + { + "local_authority_code": "E06000000222", + "local_authority_name": "Local Authority 222", + "x": 4, + "y": 7, + "average_household_income_change": 1991.8295690955406, + "relative_household_income_change": 0.0, + "population": 87265.51073046919 + }, + { + "local_authority_code": "E06000000223", + "local_authority_name": "Local Authority 223", + "x": 5, + "y": 7, + "average_household_income_change": 1857.7571143100572, + "relative_household_income_change": 0.8694923657391472, + "population": 72659.01155471863 + }, + { + "local_authority_code": "E06000000224", + "local_authority_name": "Local Authority 224", + "x": 6, + "y": 7, + "average_household_income_change": 582.8592307220295, + "relative_household_income_change": 0.0, + "population": 59591.53172435968 + }, + { + "local_authority_code": "E06000000225", + "local_authority_name": "Local Authority 225", + "x": null, + "y": null, + "average_household_income_change": -1604.9195424328059, + "relative_household_income_change": -3.5e-9, + "population": 91299.67168021623 + }, + { + "local_authority_code": "E06000000226", + "local_authority_name": "Local Authority 226", + "x": 8, + "y": 7, + "average_household_income_change": -1760.2878795178665, + "relative_household_income_change": 0.728341205120138, + "population": 59437.467109970996 + }, + { + "local_authority_code": "E06000000227", + "local_authority_name": "Local Authority 227", + "x": 9, + "y": 7, + "average_household_income_change": -1976.5708846512755, + "relative_household_income_change": 0.8609845659144635, + "population": 86764.30335666653 + }, + { + "local_authority_code": "E06000000228", + "local_authority_name": "Local Authority 228", + "x": 10, + "y": 7, + "average_household_income_change": -98.97644323572274, + "relative_household_income_change": 0.7256565627219298, + "population": 86854.77541952155 + }, + { + "local_authority_code": "E06000000229", + "local_authority_name": "Local Authority 229", + "x": 11, + "y": 7, + "average_household_income_change": -931.2346405171978, + "relative_household_income_change": 1e-12, + "population": 94065.9443818011 + }, + { + "local_authority_code": "E06000000230", + "local_authority_name": "Local Authority 230", + "x": 12, + "y": 7, + "average_household_income_change": -439.9071701350206, + "relative_household_income_change": -3.5e-9, + "population": 134163.5908219147 + }, + { + "local_authority_code": "E06000000231", + "local_authority_name": "Local Authority 231", + "x": 13, + "y": 7, + "average_household_income_change": -1591.3535006330312, + "relative_household_income_change": -0.0, + "population": 64793.23200572934 + }, + { + "local_authority_code": "E06000000232", + "local_authority_name": "Local Authority 232", + "x": null, + "y": null, + "average_household_income_change": 403.4903225216731, + "relative_household_income_change": 1e-12, + "population": 127856.55191534641 + }, + { + "local_authority_code": "E06000000233", + "local_authority_name": "Local Authority 233", + "x": 15, + "y": 7, + "average_household_income_change": 1656.1380154606948, + "relative_household_income_change": 1e-12, + "population": 111461.87010410217 + }, + { + "local_authority_code": "E06000000234", + "local_authority_name": "Local Authority 234", + "x": 16, + "y": 7, + "average_household_income_change": 1466.347687579943, + "relative_household_income_change": 0.0, + "population": 60236.23142612746 + }, + { + "local_authority_code": "E06000000235", + "local_authority_name": "Local Authority 235", + "x": 17, + "y": 7, + "average_household_income_change": -1903.0397927241559, + "relative_household_income_change": 1e-12, + "population": 99830.54271526785 + }, + { + "local_authority_code": "E06000000236", + "local_authority_name": "Local Authority 236", + "x": 18, + "y": 7, + "average_household_income_change": -1579.0994583602003, + "relative_household_income_change": 0.509998146550363, + "population": 138565.29795023135 + }, + { + "local_authority_code": "E06000000237", + "local_authority_name": "Local Authority 237", + "x": 19, + "y": 7, + "average_household_income_change": 709.2203209255472, + "relative_household_income_change": 0.0, + "population": 124197.3632538812 + }, + { + "local_authority_code": "E06000000238", + "local_authority_name": "Local Authority 238", + "x": 20, + "y": 7, + "average_household_income_change": 837.6158957883022, + "relative_household_income_change": -3.5e-9, + "population": 143454.69827079526 + }, + { + "local_authority_code": "E06000000239", + "local_authority_name": "Local Authority 239", + "x": null, + "y": null, + "average_household_income_change": -516.9005246383272, + "relative_household_income_change": -3.5e-9, + "population": 57037.494564974375 + }, + { + "local_authority_code": "E06000000240", + "local_authority_name": "Local Authority 240", + "x": 22, + "y": 7, + "average_household_income_change": 665.0094012510017, + "relative_household_income_change": -3.5e-9, + "population": 55242.63945586822 + }, + { + "local_authority_code": "E06000000241", + "local_authority_name": "Local Authority 241", + "x": 23, + "y": 7, + "average_household_income_change": 641.8757097418747, + "relative_household_income_change": 0.6890206788110149, + "population": 110740.67170974993 + }, + { + "local_authority_code": "E06000000242", + "local_authority_name": "Local Authority 242", + "x": 24, + "y": 7, + "average_household_income_change": 838.311650071085, + "relative_household_income_change": 0.0, + "population": 82383.11841393165 + }, + { + "local_authority_code": "E06000000243", + "local_authority_name": "Local Authority 243", + "x": 25, + "y": 7, + "average_household_income_change": 1034.249538488258, + "relative_household_income_change": -0.0, + "population": 52093.415519193295 + }, + { + "local_authority_code": "E06000000244", + "local_authority_name": "Local Authority 244", + "x": 26, + "y": 7, + "average_household_income_change": -1053.5590021723342, + "relative_household_income_change": -3.5e-9, + "population": 128568.646242031 + }, + { + "local_authority_code": "E06000000245", + "local_authority_name": "Local Authority 245", + "x": 27, + "y": 7, + "average_household_income_change": 413.1341063514338, + "relative_household_income_change": 1e-12, + "population": 95028.43308140374 + }, + { + "local_authority_code": "E06000000246", + "local_authority_name": "Local Authority 246", + "x": null, + "y": null, + "average_household_income_change": 726.7269295469209, + "relative_household_income_change": 0.0, + "population": 127274.43476063575 + }, + { + "local_authority_code": "E06000000247", + "local_authority_name": "Local Authority 247", + "x": 29, + "y": 7, + "average_household_income_change": 1809.5052200800587, + "relative_household_income_change": 1e-12, + "population": 59565.128533094845 + }, + { + "local_authority_code": "E06000000248", + "local_authority_name": "Local Authority 248", + "x": 30, + "y": 7, + "average_household_income_change": -1058.6836508131187, + "relative_household_income_change": 1e-12, + "population": 82279.44191562528 + }, + { + "local_authority_code": "E06000000249", + "local_authority_name": "Local Authority 249", + "x": 0, + "y": 8, + "average_household_income_change": 1448.4446061658857, + "relative_household_income_change": 0.4653143220721977, + "population": 123530.70098753371 + }, + { + "local_authority_code": "E06000000250", + "local_authority_name": "Local Authority 250", + "x": 1, + "y": 8, + "average_household_income_change": -212.2114626921525, + "relative_household_income_change": 0.852927137010199, + "population": 79092.73955186279 + }, + { + "local_authority_code": "E06000000251", + "local_authority_name": "Local Authority 251", + "x": 2, + "y": 8, + "average_household_income_change": -536.492171462005, + "relative_household_income_change": 1e-12, + "population": 54781.582558346054 + }, + { + "local_authority_code": "E06000000252", + "local_authority_name": "Local Authority 252", + "x": 3, + "y": 8, + "average_household_income_change": 1270.0802925219489, + "relative_household_income_change": 1e-12, + "population": 100948.36759624102 + }, + { + "local_authority_code": "E06000000253", + "local_authority_name": "Local Authority 253", + "x": null, + "y": null, + "average_household_income_change": -400.4186572841455, + "relative_household_income_change": 0.9118611203153317, + "population": 80534.73349695683 + }, + { + "local_authority_code": "E06000000254", + "local_authority_name": "Local Authority 254", + "x": 5, + "y": 8, + "average_household_income_change": 1436.8345817862908, + "relative_household_income_change": 0.0, + "population": 92294.26908433915 + }, + { + "local_authority_code": "E06000000255", + "local_authority_name": "Local Authority 255", + "x": 6, + "y": 8, + "average_household_income_change": 557.144812679411, + "relative_household_income_change": -3.5e-9, + "population": 110218.69910899561 + }, + { + "local_authority_code": "E06000000256", + "local_authority_name": "Local Authority 256", + "x": 7, + "y": 8, + "average_household_income_change": -258.6649908064569, + "relative_household_income_change": 1e-12, + "population": 57714.24382857021 + }, + { + "local_authority_code": "E06000000257", + "local_authority_name": "Local Authority 257", + "x": 8, + "y": 8, + "average_household_income_change": -1758.3705939542908, + "relative_household_income_change": 0.764995400685606, + "population": 131089.37081954637 + }, + { + "local_authority_code": "E06000000258", + "local_authority_name": "Local Authority 258", + "x": 9, + "y": 8, + "average_household_income_change": -1783.2292403988529, + "relative_household_income_change": 1e-12, + "population": 104494.64950338395 + }, + { + "local_authority_code": "E06000000259", + "local_authority_name": "Local Authority 259", + "x": 10, + "y": 8, + "average_household_income_change": -1327.8244041906157, + "relative_household_income_change": 1e-12, + "population": 113337.99406176846 + }, + { + "local_authority_code": "E06000000260", + "local_authority_name": "Local Authority 260", + "x": null, + "y": null, + "average_household_income_change": 684.877297170174, + "relative_household_income_change": 0.7666200435868592, + "population": 53782.60546490354 + }, + { + "local_authority_code": "E06000000261", + "local_authority_name": "Local Authority 261", + "x": 12, + "y": 8, + "average_household_income_change": -1764.2297057997705, + "relative_household_income_change": 0.0, + "population": 55563.5849283389 + }, + { + "local_authority_code": "E06000000262", + "local_authority_name": "Local Authority 262", + "x": 13, + "y": 8, + "average_household_income_change": -255.91025902887486, + "relative_household_income_change": 1e-12, + "population": 99982.94437622868 + }, + { + "local_authority_code": "E06000000263", + "local_authority_name": "Local Authority 263", + "x": 14, + "y": 8, + "average_household_income_change": -260.18748124346524, + "relative_household_income_change": 1e-12, + "population": 85148.0146588767 + }, + { + "local_authority_code": "E06000000264", + "local_authority_name": "Local Authority 264", + "x": 15, + "y": 8, + "average_household_income_change": -789.2344583138699, + "relative_household_income_change": 1e-12, + "population": 108161.34969368408 + }, + { + "local_authority_code": "E06000000265", + "local_authority_name": "Local Authority 265", + "x": 16, + "y": 8, + "average_household_income_change": -579.3256462120007, + "relative_household_income_change": -0.0, + "population": 91725.54291633592 + }, + { + "local_authority_code": "E06000000266", + "local_authority_name": "Local Authority 266", + "x": 17, + "y": 8, + "average_household_income_change": -1460.1637924293796, + "relative_household_income_change": 1e-12, + "population": 70001.25677576543 + }, + { + "local_authority_code": "E06000000267", + "local_authority_name": "Local Authority 267", + "x": null, + "y": null, + "average_household_income_change": -298.8151888887605, + "relative_household_income_change": -3.5e-9, + "population": 123887.64729678868 + }, + { + "local_authority_code": "E06000000268", + "local_authority_name": "Local Authority 268", + "x": 19, + "y": 8, + "average_household_income_change": -1492.7346834787274, + "relative_household_income_change": 0.0, + "population": 90000.38917674075 + }, + { + "local_authority_code": "E06000000269", + "local_authority_name": "Local Authority 269", + "x": 20, + "y": 8, + "average_household_income_change": 269.95612119315916, + "relative_household_income_change": 0.7682928997059275, + "population": 87441.05104136605 + }, + { + "local_authority_code": "E06000000270", + "local_authority_name": "Local Authority 270", + "x": 21, + "y": 8, + "average_household_income_change": -1775.579629167957, + "relative_household_income_change": -3.5e-9, + "population": 93847.56933635911 + }, + { + "local_authority_code": "E06000000271", + "local_authority_name": "Local Authority 271", + "x": 22, + "y": 8, + "average_household_income_change": 1917.3099341009201, + "relative_household_income_change": 0.7989601983692834, + "population": 88816.01516746833 + }, + { + "local_authority_code": "E06000000272", + "local_authority_name": "Local Authority 272", + "x": 23, + "y": 8, + "average_household_income_change": 1425.287215554047, + "relative_household_income_change": 1e-12, + "population": 131712.46458855222 + }, + { + "local_authority_code": "E06000000273", + "local_authority_name": "Local Authority 273", + "x": 24, + "y": 8, + "average_household_income_change": 214.07954117904282, + "relative_household_income_change": -0.0, + "population": 85239.5152204162 + }, + { + "local_authority_code": "E06000000274", + "local_authority_name": "Local Authority 274", + "x": null, + "y": null, + "average_household_income_change": 474.26196866968786, + "relative_household_income_change": 0.40147264541517713, + "population": 149088.15989006817 + }, + { + "local_authority_code": "E06000000275", + "local_authority_name": "Local Authority 275", + "x": 26, + "y": 8, + "average_household_income_change": 469.02445254080095, + "relative_household_income_change": 0.0, + "population": 51716.52945984774 + }, + { + "local_authority_code": "E06000000276", + "local_authority_name": "Local Authority 276", + "x": 27, + "y": 8, + "average_household_income_change": 1318.3074227966679, + "relative_household_income_change": -0.0, + "population": 143507.4378546935 + }, + { + "local_authority_code": "E06000000277", + "local_authority_name": "Local Authority 277", + "x": 28, + "y": 8, + "average_household_income_change": -488.26844121446686, + "relative_household_income_change": 0.0, + "population": 109268.00346399064 + }, + { + "local_authority_code": "E06000000278", + "local_authority_name": "Local Authority 278", + "x": 29, + "y": 8, + "average_household_income_change": 1079.9362248733341, + "relative_household_income_change": 0.4038236963373144, + "population": 79289.57169939693 + }, + { + "local_authority_code": "E06000000279", + "local_authority_name": "Local Authority 279", + "x": 30, + "y": 8, + "average_household_income_change": 393.5887992701219, + "relative_household_income_change": -0.0, + "population": 82588.28138747317 + }, + { + "local_authority_code": "E06000000280", + "local_authority_name": "Local Authority 280", + "x": 0, + "y": 9, + "average_household_income_change": -1151.3682127169345, + "relative_household_income_change": -0.0, + "population": 92957.50128701699 + }, + { + "local_authority_code": "E06000000281", + "local_authority_name": "Local Authority 281", + "x": null, + "y": null, + "average_household_income_change": -1837.624942762603, + "relative_household_income_change": 0.33343966871360353, + "population": 145490.62320200523 + }, + { + "local_authority_code": "E06000000282", + "local_authority_name": "Local Authority 282", + "x": 2, + "y": 9, + "average_household_income_change": -948.174560909842, + "relative_household_income_change": 0.6865062575083919, + "population": 126283.14237619072 + }, + { + "local_authority_code": "E06000000283", + "local_authority_name": "Local Authority 283", + "x": 3, + "y": 9, + "average_household_income_change": 749.9567568596154, + "relative_household_income_change": -3.5e-9, + "population": 133516.3436537348 + }, + { + "local_authority_code": "E06000000284", + "local_authority_name": "Local Authority 284", + "x": 4, + "y": 9, + "average_household_income_change": -1942.695785205236, + "relative_household_income_change": 0.8781793279501448, + "population": 81219.27203410128 + }, + { + "local_authority_code": "E06000000285", + "local_authority_name": "Local Authority 285", + "x": 5, + "y": 9, + "average_household_income_change": -1956.9467679013912, + "relative_household_income_change": -3.5e-9, + "population": 55900.53972903704 + }, + { + "local_authority_code": "E06000000286", + "local_authority_name": "Local Authority 286", + "x": 6, + "y": 9, + "average_household_income_change": 623.5927200680891, + "relative_household_income_change": -3.5e-9, + "population": 79076.26527150461 + }, + { + "local_authority_code": "E06000000287", + "local_authority_name": "Local Authority 287", + "x": 7, + "y": 9, + "average_household_income_change": 998.5169020724971, + "relative_household_income_change": 0.0, + "population": 121342.67637408897 + }, + { + "local_authority_code": "E06000000288", + "local_authority_name": "Local Authority 288", + "x": null, + "y": null, + "average_household_income_change": -1820.3852673716563, + "relative_household_income_change": 1e-12, + "population": 132830.3117877651 + }, + { + "local_authority_code": "E06000000289", + "local_authority_name": "Local Authority 289", + "x": 9, + "y": 9, + "average_household_income_change": 1948.3789159219884, + "relative_household_income_change": 0.12014587257431875, + "population": 68445.65221127166 + }, + { + "local_authority_code": "E06000000290", + "local_authority_name": "Local Authority 290", + "x": 10, + "y": 9, + "average_household_income_change": 423.4069254456765, + "relative_household_income_change": -3.5e-9, + "population": 66720.62706762293 + }, + { + "local_authority_code": "E06000000291", + "local_authority_name": "Local Authority 291", + "x": 11, + "y": 9, + "average_household_income_change": 281.0095652309492, + "relative_household_income_change": 0.0, + "population": 70639.58860036393 + }, + { + "local_authority_code": "E06000000292", + "local_authority_name": "Local Authority 292", + "x": 12, + "y": 9, + "average_household_income_change": 220.18925132835898, + "relative_household_income_change": -3.5e-9, + "population": 133300.24112989724 + }, + { + "local_authority_code": "E06000000293", + "local_authority_name": "Local Authority 293", + "x": 13, + "y": 9, + "average_household_income_change": 1384.243091982024, + "relative_household_income_change": 1e-12, + "population": 79745.25417884401 + }, + { + "local_authority_code": "E06000000294", + "local_authority_name": "Local Authority 294", + "x": 14, + "y": 9, + "average_household_income_change": 274.196564320758, + "relative_household_income_change": 1e-12, + "population": 115781.39119694172 + }, + { + "local_authority_code": "E06000000295", + "local_authority_name": "Local Authority 295", + "x": null, + "y": null, + "average_household_income_change": -366.2436633812365, + "relative_household_income_change": 0.2724055786550509, + "population": 118167.67210480243 + }, + { + "local_authority_code": "E06000000296", + "local_authority_name": "Local Authority 296", + "x": 16, + "y": 9, + "average_household_income_change": -416.3777338834693, + "relative_household_income_change": 0.0, + "population": 87322.12558288818 + }, + { + "local_authority_code": "E06000000297", + "local_authority_name": "Local Authority 297", + "x": 17, + "y": 9, + "average_household_income_change": -986.5104904687718, + "relative_household_income_change": 0.9976970816212194, + "population": 126700.16226183344 + }, + { + "local_authority_code": "E06000000298", + "local_authority_name": "Local Authority 298", + "x": 18, + "y": 9, + "average_household_income_change": 1280.0499716237775, + "relative_household_income_change": 1e-12, + "population": 130009.21342011327 + }, + { + "local_authority_code": "E06000000299", + "local_authority_name": "Local Authority 299", + "x": 19, + "y": 9, + "average_household_income_change": 695.194567020863, + "relative_household_income_change": -3.5e-9, + "population": 121749.1602107389 + }, + { + "local_authority_code": "E06000000300", + "local_authority_name": "Local Authority 300", + "x": 20, + "y": 9, + "average_household_income_change": 625.7473745948782, + "relative_household_income_change": 0.9887659421326285, + "population": 79438.29979595897 + }, + { + "local_authority_code": "E06000000301", + "local_authority_name": "Local Authority 301", + "x": 21, + "y": 9, + "average_household_income_change": 1707.1531326392005, + "relative_household_income_change": -3.5e-9, + "population": 90319.61710395063 + }, + { + "local_authority_code": "E06000000302", + "local_authority_name": "Local Authority 302", + "x": null, + "y": null, + "average_household_income_change": 360.74309708507803, + "relative_household_income_change": 1e-12, + "population": 78762.98544550533 + }, + { + "local_authority_code": "E06000000303", + "local_authority_name": "Local Authority 303", + "x": 23, + "y": 9, + "average_household_income_change": -1139.89488155203, + "relative_household_income_change": 0.048349037990379484, + "population": 74423.26349738428 + }, + { + "local_authority_code": "E06000000304", + "local_authority_name": "Local Authority 304", + "x": 24, + "y": 9, + "average_household_income_change": 1594.8169579207745, + "relative_household_income_change": 0.22591907290792212, + "population": 84032.00044889207 + }, + { + "local_authority_code": "E06000000305", + "local_authority_name": "Local Authority 305", + "x": 25, + "y": 9, + "average_household_income_change": -1080.1367324794787, + "relative_household_income_change": 0.9392024418031214, + "population": 121651.5707570977 + }, + { + "local_authority_code": "E06000000306", + "local_authority_name": "Local Authority 306", + "x": 26, + "y": 9, + "average_household_income_change": -331.3802060767421, + "relative_household_income_change": -3.5e-9, + "population": 95417.70209215413 + }, + { + "local_authority_code": "E06000000307", + "local_authority_name": "Local Authority 307", + "x": 27, + "y": 9, + "average_household_income_change": -1638.7362888513878, + "relative_household_income_change": 0.0, + "population": 117308.15275859929 + }, + { + "local_authority_code": "E06000000308", + "local_authority_name": "Local Authority 308", + "x": 28, + "y": 9, + "average_household_income_change": -1568.4322383589476, + "relative_household_income_change": 1e-12, + "population": 125862.9003026094 + }, + { + "local_authority_code": "E06000000309", + "local_authority_name": "Local Authority 309", + "x": null, + "y": null, + "average_household_income_change": 795.9668868388188, + "relative_household_income_change": -3.5e-9, + "population": 135858.32239161074 + }, + { + "local_authority_code": "E06000000310", + "local_authority_name": "Local Authority 310", + "x": 30, + "y": 9, + "average_household_income_change": 1933.1381935610436, + "relative_household_income_change": 0.0, + "population": 124611.61163057359 + }, + { + "local_authority_code": "E06000000311", + "local_authority_name": "Local Authority 311", + "x": 0, + "y": 10, + "average_household_income_change": -141.98181117782437, + "relative_household_income_change": -0.0, + "population": 70609.89099602692 + }, + { + "local_authority_code": "E06000000312", + "local_authority_name": "Local Authority 312", + "x": 1, + "y": 10, + "average_household_income_change": -56.44267916476542, + "relative_household_income_change": 0.0, + "population": 111699.84662409528 + }, + { + "local_authority_code": "E06000000313", + "local_authority_name": "Local Authority 313", + "x": 2, + "y": 10, + "average_household_income_change": 1778.0555217769213, + "relative_household_income_change": -3.5e-9, + "population": 145693.45404754783 + }, + { + "local_authority_code": "E06000000314", + "local_authority_name": "Local Authority 314", + "x": 3, + "y": 10, + "average_household_income_change": -1278.5715098616452, + "relative_household_income_change": -3.5e-9, + "population": 76006.43282400742 + }, + { + "local_authority_code": "E06000000315", + "local_authority_name": "Local Authority 315", + "x": 4, + "y": 10, + "average_household_income_change": -1059.1789998677025, + "relative_household_income_change": -3.5e-9, + "population": 65529.9637408226 + }, + { + "local_authority_code": "E06000000316", + "local_authority_name": "Local Authority 316", + "x": null, + "y": null, + "average_household_income_change": -1048.4707691627543, + "relative_household_income_change": 0.0, + "population": 145955.27596523814 + }, + { + "local_authority_code": "E06000000317", + "local_authority_name": "Local Authority 317", + "x": 6, + "y": 10, + "average_household_income_change": -526.7537120829884, + "relative_household_income_change": -0.0, + "population": 145820.206722863 + }, + { + "local_authority_code": "E06000000318", + "local_authority_name": "Local Authority 318", + "x": 7, + "y": 10, + "average_household_income_change": -1953.7169568476536, + "relative_household_income_change": 0.0, + "population": 145527.70903551613 + }, + { + "local_authority_code": "E06000000319", + "local_authority_name": "Local Authority 319", + "x": 8, + "y": 10, + "average_household_income_change": 1905.364779664637, + "relative_household_income_change": -0.0, + "population": 54054.22553493581 + }, + { + "local_authority_code": "E06000000320", + "local_authority_name": "Local Authority 320", + "x": 9, + "y": 10, + "average_household_income_change": 294.1624951426873, + "relative_household_income_change": -3.5e-9, + "population": 53783.2750109446 + }, + { + "local_authority_code": "E06000000321", + "local_authority_name": "Local Authority 321", + "x": 10, + "y": 10, + "average_household_income_change": -548.0681646499228, + "relative_household_income_change": 1e-12, + "population": 124067.3608380831 + }, + { + "local_authority_code": "E06000000322", + "local_authority_name": "Local Authority 322", + "x": 11, + "y": 10, + "average_household_income_change": -920.382750538947, + "relative_household_income_change": 1e-12, + "population": 64399.62052101023 + }, + { + "local_authority_code": "E06000000323", + "local_authority_name": "Local Authority 323", + "x": null, + "y": null, + "average_household_income_change": 1350.0455770723922, + "relative_household_income_change": 1e-12, + "population": 62873.80233865063 + }, + { + "local_authority_code": "E06000000324", + "local_authority_name": "Local Authority 324", + "x": 13, + "y": 10, + "average_household_income_change": -922.7161566266906, + "relative_household_income_change": 0.19694956380898854, + "population": 106076.4762961037 + }, + { + "local_authority_code": "E06000000325", + "local_authority_name": "Local Authority 325", + "x": 14, + "y": 10, + "average_household_income_change": -1002.6916131420753, + "relative_household_income_change": 0.0, + "population": 62774.55803733909 + }, + { + "local_authority_code": "E06000000326", + "local_authority_name": "Local Authority 326", + "x": 15, + "y": 10, + "average_household_income_change": 1663.6719409919683, + "relative_household_income_change": -0.0, + "population": 67807.47080663007 + }, + { + "local_authority_code": "E06000000327", + "local_authority_name": "Local Authority 327", + "x": 16, + "y": 10, + "average_household_income_change": -1411.6340938270553, + "relative_household_income_change": -0.0, + "population": 147665.7414411456 + }, + { + "local_authority_code": "E06000000328", + "local_authority_name": "Local Authority 328", + "x": 17, + "y": 10, + "average_household_income_change": -1612.4841951460103, + "relative_household_income_change": 0.0, + "population": 124470.9080460218 + }, + { + "local_authority_code": "E06000000329", + "local_authority_name": "Local Authority 329", + "x": 18, + "y": 10, + "average_household_income_change": -442.1381548896619, + "relative_household_income_change": -3.5e-9, + "population": 122077.83443159891 + }, + { + "local_authority_code": "E06000000330", + "local_authority_name": "Local Authority 330", + "x": null, + "y": null, + "average_household_income_change": 693.5865859513317, + "relative_household_income_change": 1e-12, + "population": 146696.59035836256 + }, + { + "local_authority_code": "E06000000331", + "local_authority_name": "Local Authority 331", + "x": 20, + "y": 10, + "average_household_income_change": -692.0955580068231, + "relative_household_income_change": -0.0, + "population": 57876.679797211415 + }, + { + "local_authority_code": "E06000000332", + "local_authority_name": "Local Authority 332", + "x": 21, + "y": 10, + "average_household_income_change": -1519.174748158672, + "relative_household_income_change": -0.0, + "population": 95734.55317987516 + }, + { + "local_authority_code": "E06000000333", + "local_authority_name": "Local Authority 333", + "x": 22, + "y": 10, + "average_household_income_change": -1442.6525762495714, + "relative_household_income_change": -0.0, + "population": 101590.51841877076 + }, + { + "local_authority_code": "E06000000334", + "local_authority_name": "Local Authority 334", + "x": 23, + "y": 10, + "average_household_income_change": 1790.1936017105982, + "relative_household_income_change": 0.8036893103680613, + "population": 60227.68191232477 + }, + { + "local_authority_code": "E06000000335", + "local_authority_name": "Local Authority 335", + "x": 24, + "y": 10, + "average_household_income_change": -657.7156834947568, + "relative_household_income_change": 0.0, + "population": 56033.994244607464 + }, + { + "local_authority_code": "E06000000336", + "local_authority_name": "Local Authority 336", + "x": 25, + "y": 10, + "average_household_income_change": -93.12370767006372, + "relative_household_income_change": 1e-12, + "population": 74251.63287615858 + }, + { + "local_authority_code": "E06000000337", + "local_authority_name": "Local Authority 337", + "x": null, + "y": null, + "average_household_income_change": -209.0733302843737, + "relative_household_income_change": 0.0, + "population": 92647.79125130326 + }, + { + "local_authority_code": "E06000000338", + "local_authority_name": "Local Authority 338", + "x": 27, + "y": 10, + "average_household_income_change": 1354.5181812972617, + "relative_household_income_change": -0.0, + "population": 82370.15096479152 + }, + { + "local_authority_code": "E06000000339", + "local_authority_name": "Local Authority 339", + "x": 28, + "y": 10, + "average_household_income_change": -492.64160858524696, + "relative_household_income_change": 0.0, + "population": 86770.08718112283 + }, + { + "local_authority_code": "E06000000340", + "local_authority_name": "Local Authority 340", + "x": 29, + "y": 10, + "average_household_income_change": -995.9443815964817, + "relative_household_income_change": -0.0, + "population": 135610.1666875653 + }, + { + "local_authority_code": "E06000000341", + "local_authority_name": "Local Authority 341", + "x": 30, + "y": 10, + "average_household_income_change": -0.39287356256378914, + "relative_household_income_change": 0.5297196338472642, + "population": 74252.93126537 + }, + { + "local_authority_code": "E06000000342", + "local_authority_name": "Local Authority 342", + "x": 0, + "y": 11, + "average_household_income_change": 944.950333059178, + "relative_household_income_change": 0.0, + "population": 106059.18237216485 + }, + { + "local_authority_code": "E06000000343", + "local_authority_name": "Local Authority 343", + "x": 1, + "y": 11, + "average_household_income_change": 64.77822243535725, + "relative_household_income_change": 0.0, + "population": 53563.437046919236 + }, + { + "local_authority_code": "E06000000344", + "local_authority_name": "Local Authority 344", + "x": null, + "y": null, + "average_household_income_change": -570.1810329346469, + "relative_household_income_change": -3.5e-9, + "population": 141799.32131303017 + }, + { + "local_authority_code": "E06000000345", + "local_authority_name": "Local Authority 345", + "x": 3, + "y": 11, + "average_household_income_change": -149.65675964463588, + "relative_household_income_change": 1e-12, + "population": 138491.2752178312 + }, + { + "local_authority_code": "E06000000346", + "local_authority_name": "Local Authority 346", + "x": 4, + "y": 11, + "average_household_income_change": 1519.5945289635704, + "relative_household_income_change": 0.3885347278781518, + "population": 127943.93117060614 + }, + { + "local_authority_code": "E06000000347", + "local_authority_name": "Local Authority 347", + "x": 5, + "y": 11, + "average_household_income_change": -240.12834747409696, + "relative_household_income_change": -0.0, + "population": 148496.6980579185 + }, + { + "local_authority_code": "E06000000348", + "local_authority_name": "Local Authority 348", + "x": 6, + "y": 11, + "average_household_income_change": -1184.3496615419813, + "relative_household_income_change": 0.40605730599907053, + "population": 52620.71908102276 + }, + { + "local_authority_code": "E06000000349", + "local_authority_name": "Local Authority 349", + "x": 7, + "y": 11, + "average_household_income_change": 128.04545237281982, + "relative_household_income_change": -0.0, + "population": 145538.13077819993 + }, + { + "local_authority_code": "E06000000350", + "local_authority_name": "Local Authority 350", + "x": 8, + "y": 11, + "average_household_income_change": 1616.6229865208313, + "relative_household_income_change": 0.0, + "population": 115389.84845951825 + }, + { + "local_authority_code": "E06000000351", + "local_authority_name": "Local Authority 351", + "x": null, + "y": null, + "average_household_income_change": -137.77954457466944, + "relative_household_income_change": 1e-12, + "population": 90290.34582224516 + }, + { + "local_authority_code": "E06000000352", + "local_authority_name": "Local Authority 352", + "x": 10, + "y": 11, + "average_household_income_change": -758.6668108760035, + "relative_household_income_change": 1e-12, + "population": 82891.8878076119 + }, + { + "local_authority_code": "E06000000353", + "local_authority_name": "Local Authority 353", + "x": 11, + "y": 11, + "average_household_income_change": -439.1523756355236, + "relative_household_income_change": 0.9976709354222745, + "population": 59413.06245577177 + }, + { + "local_authority_code": "E06000000354", + "local_authority_name": "Local Authority 354", + "x": 12, + "y": 11, + "average_household_income_change": 1341.2342875125476, + "relative_household_income_change": -0.0, + "population": 123965.39796798336 + }, + { + "local_authority_code": "E06000000355", + "local_authority_name": "Local Authority 355", + "x": 13, + "y": 11, + "average_household_income_change": -1339.4924488888353, + "relative_household_income_change": 0.0, + "population": 138213.72660216942 + }, + { + "local_authority_code": "E06000000356", + "local_authority_name": "Local Authority 356", + "x": 14, + "y": 11, + "average_household_income_change": 1461.0173356186356, + "relative_household_income_change": -3.5e-9, + "population": 109738.91820518073 + }, + { + "local_authority_code": "E06000000357", + "local_authority_name": "Local Authority 357", + "x": 15, + "y": 11, + "average_household_income_change": 1001.7597486741581, + "relative_household_income_change": 1e-12, + "population": 90421.41316384055 + }, + { + "local_authority_code": "E06000000358", + "local_authority_name": "Local Authority 358", + "x": null, + "y": null, + "average_household_income_change": 1368.5395672388715, + "relative_household_income_change": -3.5e-9, + "population": 105507.23691464387 + }, + { + "local_authority_code": "E06000000359", + "local_authority_name": "Local Authority 359", + "x": 17, + "y": 11, + "average_household_income_change": -1467.9886853886562, + "relative_household_income_change": 1e-12, + "population": 109966.1632713356 + }, + { + "local_authority_code": "E06000000360", + "local_authority_name": "Local Authority 360", + "x": 18, + "y": 11, + "average_household_income_change": -1471.8412412220912, + "relative_household_income_change": 1e-12, + "population": 136124.82700437307 + } + ], + "congressional_district_impact": null, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_budget_window_year_2030.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_budget_window_year_2030.json new file mode 100644 index 000000000..2609876a0 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_budget_window_year_2030.json @@ -0,0 +1,243 @@ +{ + "spm_provenance": null, + "model_version": "1.715.2", + "data_version": "1.115.5", + "budget": { + "tax_revenue_impact": -79366793153.85684, + "state_tax_revenue_impact": -207883514.77863798, + "benefit_spending_impact": -69005545839.51794, + "budgetary_impact": -52790382052.5131, + "households": 121330301.9135918, + "baseline_net_income": 11803182028970.15 + }, + "detailed_budget": { + "income_tax": { + "baseline": 835910086.1754378, + "reform": 600904702.991617, + "difference": 530325205.0108769 + }, + "snap": { + "baseline": -556143648.6193647, + "reform": 73360016.34962708, + "difference": -446634713.1170996 + }, + "eitc": { + "baseline": -654670941.4292622, + "reform": -787633415.1369398, + "difference": -571199134.842167 + }, + "ctc": { + "baseline": 854951262.856121, + "reform": 657840097.5568388, + "difference": 613304693.4046469 + }, + "ssi": { + "baseline": 600895677.0859324, + "reform": -613128763.96152, + "difference": -380300085.40092885 + }, + "state_income_tax": { + "baseline": 253951204.8262602, + "reform": 463789417.757436, + "difference": 709296715.9826944 + } + }, + "decile": { + "average": { + "1": 2280.304508167196, + "2": -2479.690484190469, + "3": 635.1113023192792, + "4": 1030.2087390988397, + "5": 35.72265426249199, + "6": -1933.2589545552933, + "7": -158.47267448912916, + "8": -2463.922756407658, + "9": 2607.5301833994486, + "10": 2192.9050211104995 + }, + "relative": { + "1": 1e-12, + "2": 1e-12, + "3": 1e-12, + "4": 0.8480440882236346, + "5": -3.5e-9, + "6": -3.5e-9, + "7": -0.0, + "8": 0.0, + "9": -3.5e-9, + "10": 1e-12 + } + }, + "inequality": { + "gini": { + "baseline": 0.5188720862166717, + "reform": 0.6479980831685668 + }, + "top_10_pct_share": { + "baseline": 0.7008787195596342, + "reform": 0.14506275949834935 + }, + "top_1_pct_share": { + "baseline": 0.673778811064165, + "reform": 0.0664384693860609 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.9135318190058366, + "reform": 0.6350191059162094 + }, + "adult": { + "baseline": 0.44115195629227777, + "reform": 0.18370364871495026 + }, + "senior": { + "baseline": 0.4359243344287773, + "reform": 0.5887686783125169 + }, + "all": { + "baseline": 0.6344321858773304, + "reform": 0.19871219921346828 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.32353421375885183, + "reform": 0.83942633913167 + }, + "adult": { + "baseline": 0.7081624882584023, + "reform": 0.2774787900236415 + }, + "senior": { + "baseline": 0.582200464721263, + "reform": 0.86209904579384 + }, + "all": { + "baseline": 0.12214540191789702, + "reform": 0.9345807520861901 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.29092425449951553, + "reform": 0.025676079413378128 + }, + "female": { + "baseline": 0.35708404851922304, + "reform": 0.08261033815091678 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.9644747075046419, + "reform": 0.2856861258661594 + }, + "female": { + "baseline": 0.6752132248868613, + "reform": 0.32712215943774225 + } + } + }, + "poverty_by_race": { + "poverty": { + "white": { + "baseline": 0.32281026459482653, + "reform": 0.3216761199087408 + }, + "black": { + "baseline": 0.15290962737232017, + "reform": 0.6519226015391028 + }, + "hispanic": { + "baseline": 0.8613730888315168, + "reform": 0.8695065350868587 + }, + "other": { + "baseline": 0.6800437183523759, + "reform": 0.07775628785349609 + } + } + }, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.6177296429232895, + 0.8940836745809185, + 0.2919953326615514, + 0.25004737363483476, + 0.5988096666471184, + 0.15890465923627628, + 0.5730900616153811, + 0.363324836230257, + 0.45478533334707494, + 0.3651908247451906 + ], + "Gain less than 5%": [ + 0.8043490749800523, + 0.9971852622206079, + 0.5714557550631628, + 0.43932677046767354, + 0.20723672527039427, + 0.9165298060172561, + 0.11361823275193217, + 0.06220918934993902, + 0.7371214193751683, + 0.5955348363282316 + ], + "No change": [ + 0.9706188078942934, + 0.6065174865789142, + 0.5462300441463859, + 0.5826650727470118, + 0.32143295554545914, + 0.035573952811012055, + 0.8348504493329046, + 0.29290741513514607, + 0.9969057545760365, + 0.6516121773992284 + ], + "Lose less than 5%": [ + 0.20026142506124045, + 0.20177274377541365, + 0.43881418861912547, + 0.49193865833334016, + 0.2190615044625175, + 0.44350210762460507, + 0.6474356568552584, + 0.4278369309831196, + 0.21571977808013343, + 0.18771009170748554 + ], + "Lose more than 5%": [ + 0.03680596690173188, + 0.25338737847200865, + 0.5256771386270633, + 0.7723155932982761, + 0.41713471613607933, + 0.261687242550416, + 0.3250140700584182, + 0.8910254979732124, + 0.3145167050163761, + 0.11685600869782797 + ] + }, + "all": { + "Gain more than 5%": 0.5697090059739505, + "Gain less than 5%": 0.9627552174102675, + "No change": 0.6529674578754117, + "Lose less than 5%": 0.8708981110964545, + "Lose more than 5%": 0.7173524703437558 + } + }, + "wealth_decile": null, + "intra_wealth_decile": null, + "labor_supply_response": null, + "constituency_impact": null, + "local_authority_impact": null, + "congressional_district_impact": null, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_congressional_district_ca_12_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_congressional_district_ca_12_2026.json new file mode 100644 index 000000000..afc1a5003 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_congressional_district_ca_12_2026.json @@ -0,0 +1,301 @@ +{ + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": { + "receipt": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2022-2036", + "forecast_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "scenario": "central", + "geography_kind": "county", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": [ + "2026" + ], + "runtime_versions": { + "policyengine": "6.10.0", + "policyengine-core": "3.20.0", + "policyengine-us": "1.715.2", + "spm-calculator": "0.4.1" + } + }, + "execution_count": 2 + }, + "reform": { + "receipt": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2022-2036", + "forecast_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "scenario": "central", + "geography_kind": "county", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": [ + "2026" + ], + "runtime_versions": { + "policyengine": "6.10.0", + "policyengine-core": "3.20.0", + "policyengine-us": "1.715.2", + "spm-calculator": "0.4.1" + } + }, + "execution_count": 2 + } + }, + "model_version": "1.715.2", + "data_version": "1.115.5", + "budget": { + "tax_revenue_impact": 8845845059.19037, + "state_tax_revenue_impact": -260089666.9038415, + "benefit_spending_impact": 20784007719.238895, + "budgetary_impact": -52407074581.62173, + "households": 132514406.08216108, + "baseline_net_income": 11131057718479.627 + }, + "detailed_budget": { + "income_tax": { + "baseline": -973664016.8902518, + "reform": 674938164.19292, + "difference": -481291971.3439847 + }, + "snap": { + "baseline": -531338077.9066073, + "reform": 991289671.0209255, + "difference": -59472984.95510411 + }, + "eitc": { + "baseline": 672922902.5487775, + "reform": -47293582.601330094, + "difference": 278136281.0883239 + }, + "ctc": { + "baseline": -698767151.9529521, + "reform": 269721316.5703769, + "difference": 736090614.2865936 + }, + "ssi": { + "baseline": 46362420.76660269, + "reform": 482503712.4029805, + "difference": 342822950.7391851 + }, + "state_income_tax": { + "baseline": -871937123.5460054, + "reform": 516460492.5736347, + "difference": 182199165.8626352 + } + }, + "decile": { + "average": { + "1": -1192.394042905726, + "2": -2813.9294911815, + "3": 2193.1634218736735, + "4": -163.505468007199, + "5": 1312.9435443948184, + "6": 2272.87680153289, + "7": 1284.7769016672153, + "8": 2526.592005503247, + "9": -630.2195759955365, + "10": 1805.4526259113698 + }, + "relative": { + "1": -0.0, + "2": 0.0, + "3": -3.5e-9, + "4": -3.5e-9, + "5": 1e-12, + "6": -3.5e-9, + "7": 0.5740227352895624, + "8": 0.5850741074053635, + "9": 1e-12, + "10": 0.0 + } + }, + "inequality": { + "gini": { + "baseline": 0.8564005663967557, + "reform": 0.9909896448688151 + }, + "top_10_pct_share": { + "baseline": 0.6712735421625182, + "reform": 0.16309962197106975 + }, + "top_1_pct_share": { + "baseline": 0.8606375331162682, + "reform": 0.9646329473090614 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.9046959845122367, + "reform": 0.5691075034743235 + }, + "adult": { + "baseline": 0.7138170201741992, + "reform": 0.2111249836755983 + }, + "senior": { + "baseline": 0.8316079302733542, + "reform": 0.5735323523512847 + }, + "all": { + "baseline": 0.2849574619862052, + "reform": 0.06346057714522935 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.8539424884226802, + "reform": 0.9898060149215813 + }, + "adult": { + "baseline": 0.08851809310972836, + "reform": 0.8005953212575019 + }, + "senior": { + "baseline": 0.41046182734590886, + "reform": 0.15076537445280958 + }, + "all": { + "baseline": 0.2938912468190622, + "reform": 0.7687918872773446 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.8727670246282013, + "reform": 0.04419006112954338 + }, + "female": { + "baseline": 0.6145325285318086, + "reform": 0.0449402434960362 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.7184404774485162, + "reform": 0.3309541460190075 + }, + "female": { + "baseline": 0.880905307247358, + "reform": 0.9806357568433396 + } + } + }, + "poverty_by_race": { + "poverty": { + "white": { + "baseline": 0.505420373648044, + "reform": 0.9985089453757765 + }, + "black": { + "baseline": 0.309670053476339, + "reform": 0.0769707047054119 + }, + "hispanic": { + "baseline": 0.5997628087966007, + "reform": 0.031377762175317736 + }, + "other": { + "baseline": 0.1973848564284194, + "reform": 0.4079361356169908 + } + } + }, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.6104671229673415, + 0.15619899101356471, + 0.04243582472120422, + 0.8677790339277224, + 0.3138305199160917, + 0.958659426408455, + 0.8966596414276016, + 0.3777892394121827, + 0.46040963284590475, + 0.5200729845925639 + ], + "Gain less than 5%": [ + 0.6438887183877269, + 0.5956502384019705, + 0.5592610620153905, + 0.620126135445262, + 0.9406212554239632, + 0.5070268159456598, + 0.43119155343093274, + 0.7203112521441384, + 0.23763561946478406, + 0.3010868611741494 + ], + "No change": [ + 0.9777973164486353, + 0.521127293281206, + 0.5484304676868622, + 0.01145748636421906, + 0.415210343803882, + 0.5799652137970656, + 0.020052890304599336, + 0.6157979413062568, + 0.6321805352961154, + 0.060080510627723016 + ], + "Lose less than 5%": [ + 0.627341109010956, + 0.4662504296967078, + 0.6792813980381086, + 0.35257698304104734, + 0.706950249365899, + 0.7380342892520343, + 0.02218246899080989, + 0.06057680364579732, + 0.6760203094873768, + 0.9633055803862574 + ], + "Lose more than 5%": [ + 0.2511222781834702, + 0.45631212963637924, + 0.5926718756664892, + 0.32002538574800654, + 0.3639550893399569, + 0.3126706611969108, + 0.36915397527695404, + 0.5956215058575365, + 0.3004039733655893, + 0.37716034132891685 + ] + }, + "all": { + "Gain more than 5%": 0.772273412444121, + "Gain less than 5%": 0.02692120500933004, + "No change": 0.5692580020705822, + "Lose less than 5%": 0.7351731816785095, + "Lose more than 5%": 0.310016695410093 + } + }, + "wealth_decile": null, + "intra_wealth_decile": null, + "labor_supply_response": null, + "constituency_impact": null, + "local_authority_impact": null, + "congressional_district_impact": { + "districts": [ + { + "district": "CA-01", + "average_household_income_change": -1950.8870104080981, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.14085523548621137, + "loser_percentage": 0.04326821221926003, + "no_change_percentage": 0.9502674871120121, + "population": 1070424.2919654003 + } + ] + }, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_national_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_national_2026.json new file mode 100644 index 000000000..21c4fa9e3 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_national_2026.json @@ -0,0 +1,4295 @@ +{ + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": { + "receipt": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2022-2036", + "forecast_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "scenario": "central", + "geography_kind": "county", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": [ + "2026" + ], + "runtime_versions": { + "policyengine": "6.10.0", + "policyengine-core": "3.20.0", + "policyengine-us": "1.715.2", + "spm-calculator": "0.4.1" + } + }, + "execution_count": 2 + }, + "reform": { + "receipt": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2022-2036", + "forecast_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "scenario": "central", + "geography_kind": "county", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": [ + "2026" + ], + "runtime_versions": { + "policyengine": "6.10.0", + "policyengine-core": "3.20.0", + "policyengine-us": "1.715.2", + "spm-calculator": "0.4.1" + } + }, + "execution_count": 2 + } + }, + "model_version": "1.715.2", + "data_version": "1.115.5", + "budget": { + "tax_revenue_impact": 69486747387.44653, + "state_tax_revenue_impact": 527549237.95322806, + "benefit_spending_impact": -48986194852.11566, + "budgetary_impact": -73127151177.51976, + "households": 129908701.74183881, + "baseline_net_income": 11898982129577.477 + }, + "detailed_budget": { + "income_tax": { + "baseline": 303185945.4455259, + "reform": 577446702.2710264, + "difference": -812280826.4515302 + }, + "snap": { + "baseline": -943305046.9559873, + "reform": 671530207.8397393, + "difference": -134465864.18989325 + }, + "eitc": { + "baseline": 524560164.91588396, + "reform": -995787893.2977786, + "difference": -109225611.89039715 + }, + "ctc": { + "baseline": 443080064.6815651, + "reform": -542475557.4590948, + "difference": 890541391.1078446 + }, + "ssi": { + "baseline": 802854915.2229671, + "reform": -938820033.9328929, + "difference": -949108278.0130785 + }, + "state_income_tax": { + "baseline": 82824945.58699316, + "reform": 878298325.5570211, + "difference": -237591524.62357512 + } + }, + "decile": { + "average": { + "1": -1700.4036172163196, + "2": -467.3005465036961, + "3": -2825.7552745507924, + "4": -1669.8500023617896, + "5": -372.67443809656766, + "6": -25.126551708896105, + "7": -1601.4932984545642, + "8": -1614.8007507540942, + "9": -1687.3137759738684, + "10": -242.37920557359848 + }, + "relative": { + "1": 0.0, + "2": 0.4161799388943461, + "3": 0.0, + "4": 1e-12, + "5": 1e-12, + "6": 0.8955753946414917, + "7": -3.5e-9, + "8": -0.0, + "9": 0.3033685109329176, + "10": -3.5e-9 + } + }, + "inequality": { + "gini": { + "baseline": 0.8461974184283128, + "reform": 0.5052838205796004 + }, + "top_10_pct_share": { + "baseline": 0.5890022579825517, + "reform": 0.034525830151341586 + }, + "top_1_pct_share": { + "baseline": 0.24273997354306764, + "reform": 0.7974042475543028 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.4143139993007743, + "reform": 0.17300740157905092 + }, + "adult": { + "baseline": 0.548798761388153, + "reform": 0.7030407620656315 + }, + "senior": { + "baseline": 0.6744858305023272, + "reform": 0.3747030205016403 + }, + "all": { + "baseline": 0.4389616300445631, + "reform": 0.5084264882499818 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.7784426150001458, + "reform": 0.5209384176131452 + }, + "adult": { + "baseline": 0.39325509496422606, + "reform": 0.4896935204622582 + }, + "senior": { + "baseline": 0.029574963966907064, + "reform": 0.04348729035652743 + }, + "all": { + "baseline": 0.703382088603836, + "reform": 0.9831877173096739 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.5931837303800576, + "reform": 0.393599686377914 + }, + "female": { + "baseline": 0.17034919685568128, + "reform": 0.5022385584334831 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.9820766375385342, + "reform": 0.7705231398308006 + }, + "female": { + "baseline": 0.5396174484497788, + "reform": 0.8602897789205496 + } + } + }, + "poverty_by_race": { + "poverty": { + "white": { + "baseline": 0.23217612806301458, + "reform": 0.513771663187637 + }, + "black": { + "baseline": 0.9524673882682695, + "reform": 0.5777948078012031 + }, + "hispanic": { + "baseline": 0.45913173191066836, + "reform": 0.2692794774414212 + }, + "other": { + "baseline": 0.5479963094662489, + "reform": 0.9571162814602269 + } + } + }, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.005709129450392925, + 0.7836552326153898, + 0.8204859119254819, + 0.8861795808260082, + 0.7405034118331963, + 0.8091399008724796, + 0.518678283523002, + 0.561357864778379, + 0.4260906796881502, + 0.05612329752074041 + ], + "Gain less than 5%": [ + 0.8700101551766398, + 0.5699993338763802, + 0.19983942017714307, + 0.5047204674288633, + 0.48492511222773416, + 0.3567899645449557, + 0.3460779190181549, + 0.5384787957378443, + 0.6234894527975051, + 0.6124524647827256 + ], + "No change": [ + 0.4581468000997244, + 0.027974984083842358, + 0.22960503127702392, + 0.1772112589385827, + 0.5844608707784413, + 0.8610088608533248, + 0.798438940577426, + 0.7970975626354962, + 0.8164373705606909, + 0.25529404008730594 + ], + "Lose less than 5%": [ + 0.841744832274096, + 0.6731135254387071, + 0.08323413780389788, + 0.0166906301155596, + 0.014559974924812313, + 0.7555867752521982, + 0.2495592256534228, + 0.10948862729435938, + 0.6248020841524763, + 0.3444228640964949 + ], + "Lose more than 5%": [ + 0.06951537853084733, + 0.1596255246938475, + 0.5273803990480128, + 0.16814494622242826, + 0.2729144368186801, + 0.7115899271852729, + 0.4547016300456639, + 0.3220017663873259, + 0.4737710141702789, + 0.023634577631987064 + ] + }, + "all": { + "Gain more than 5%": 0.38655710476146987, + "Gain less than 5%": 0.4209186792090759, + "No change": 0.18803930475131292, + "Lose less than 5%": 0.10876169244541334, + "Lose more than 5%": 0.8998185003560202 + } + }, + "wealth_decile": null, + "intra_wealth_decile": null, + "labor_supply_response": { + "substitution_lsr": -95240892.98036276, + "income_lsr": 119544772.1609919, + "relative_lsr": { + "income": -3.5e-9, + "substitution": 0.45183232059956535 + }, + "total_change": -620195937.3852562, + "revenue_change": 607800625.4420971, + "decile": { + "average": { + "income": { + "1": -48.473527582247435, + "2": 227.91766811872827, + "3": -627.6526288073741, + "4": -106.8136944860032, + "5": -716.4097414810877, + "6": 77.387017155357, + "7": 780.7609840249787, + "8": 268.905565766697, + "9": 190.8730116813393, + "10": -207.68004708353027 + }, + "substitution": { + "1": -94.01026883836794, + "2": 477.83176028076844, + "3": 300.1419478281451, + "4": 246.25690303743974, + "5": 663.4032506999561, + "6": -873.6701598830357, + "7": -928.6944390802345, + "8": 759.1299450687021, + "9": 199.23616990570105, + "10": 556.2168130864866 + } + }, + "relative": { + "income": { + "1": -347.4132949034705, + "2": 181.91888693611503, + "3": -609.3572449814661, + "4": -532.7087247328207, + "5": -411.69685870747054, + "6": -990.8124556772482, + "7": -830.022477249356, + "8": 309.60327801351093, + "9": -186.4425242296428, + "10": 102.53547125595253 + }, + "substitution": { + "1": 863.3704459699325, + "2": -833.5930450587033, + "3": -492.01051421050425, + "4": 515.9800980935379, + "5": 25.769282714127595, + "6": -940.4961754348353, + "7": 126.28452131581125, + "8": -784.146168822053, + "9": -784.3937896701154, + "10": -418.1942137105237 + } + } + }, + "hours": { + "baseline": -8663469857.26917, + "reform": -9662190549.476479, + "change": 3697949.498973032, + "income_effect": -5729966.430408149, + "substitution_effect": 8540842.966775883 + } + }, + "constituency_impact": null, + "local_authority_impact": null, + "congressional_district_impact": { + "districts": [ + { + "district": "AL-01", + "average_household_income_change": -101.7172852458077, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.14260035292536777, + "loser_percentage": 0.01086044309006795, + "no_change_percentage": 0.3747544920633644, + "population": 548096.2789566629 + }, + { + "district": "AL-02", + "average_household_income_change": 1241.3922089403352, + "relative_household_income_change": 0.6905926530386846, + "winner_percentage": 0.22764436571441626, + "loser_percentage": 0.0016658802428921593, + "no_change_percentage": 0.6242328818537503, + "population": 1956080.890298706 + }, + { + "district": "AL-03", + "average_household_income_change": -529.378558329722, + "relative_household_income_change": -0.0, + "winner_percentage": 0.05882432741086607, + "loser_percentage": 0.8188201278420804, + "no_change_percentage": 0.07460963363587747, + "population": 1373891.0081870835 + }, + { + "district": "AL-04", + "average_household_income_change": -652.0016288329296, + "relative_household_income_change": 0.0, + "winner_percentage": 0.018604300312002775, + "loser_percentage": 0.06078469618896887, + "no_change_percentage": 0.9150336434099469, + "population": 1017851.832855403 + }, + { + "district": "AL-05", + "average_household_income_change": -1636.0880437656667, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.11252763614702643, + "loser_percentage": 0.4232066122540936, + "no_change_percentage": 0.13506950094916403, + "population": 625081.3883845364 + }, + { + "district": "AL-06", + "average_household_income_change": 485.807793085729, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5551503462434865, + "loser_percentage": 0.5072476701117876, + "no_change_percentage": 0.0836027964988253, + "population": 1214877.9991096288 + }, + { + "district": "AL-07", + "average_household_income_change": 672.5957273236207, + "relative_household_income_change": 0.46959011611914814, + "winner_percentage": 0.38458139107487466, + "loser_percentage": 0.030494381790816316, + "no_change_percentage": 0.7263274273474801, + "population": 1934917.7657345892 + }, + { + "district": "AK-01", + "average_household_income_change": 1905.2150782139736, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9410878223413865, + "loser_percentage": 0.8661318249263946, + "no_change_percentage": 0.38559418673644075, + "population": 619805.2333866417 + }, + { + "district": "AZ-01", + "average_household_income_change": -990.4892374583359, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5113505018743109, + "loser_percentage": 0.8101070729276686, + "no_change_percentage": 0.5000121234313284, + "population": 56286.53438263037 + }, + { + "district": "AZ-02", + "average_household_income_change": 1290.2704733337002, + "relative_household_income_change": 0.43015437597568906, + "winner_percentage": 0.025740945504366897, + "loser_percentage": 0.675654354894482, + "no_change_percentage": 0.043483476253197484, + "population": 408810.50783274416 + }, + { + "district": "AZ-03", + "average_household_income_change": 429.15350504556795, + "relative_household_income_change": -0.0, + "winner_percentage": 0.17804952768967286, + "loser_percentage": 0.6958955397739035, + "no_change_percentage": 0.03684608961256153, + "population": 553095.2587470373 + }, + { + "district": "AZ-04", + "average_household_income_change": -623.286050496465, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5140179727916571, + "loser_percentage": 0.36830555803174847, + "no_change_percentage": 0.3396596259019955, + "population": 911261.5805307392 + }, + { + "district": "AZ-05", + "average_household_income_change": 787.7910720928934, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9558918773273261, + "loser_percentage": 0.1675630022937611, + "no_change_percentage": 0.924249742544119, + "population": 728385.5554744796 + }, + { + "district": "AZ-06", + "average_household_income_change": -1243.0708775985843, + "relative_household_income_change": 0.7485277743628934, + "winner_percentage": 0.006674252060786756, + "loser_percentage": 0.2682540484924081, + "no_change_percentage": 0.7706727594239515, + "population": 683579.4773629309 + }, + { + "district": "AZ-07", + "average_household_income_change": -1033.6530865744967, + "relative_household_income_change": 0.0, + "winner_percentage": 0.2475557298889539, + "loser_percentage": 0.11605849850039973, + "no_change_percentage": 0.3609521686918671, + "population": 1093124.59086775 + }, + { + "district": "AZ-08", + "average_household_income_change": -1380.7734243896737, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6394110628821505, + "loser_percentage": 0.22951422470938387, + "no_change_percentage": 0.5272370763287079, + "population": 954776.9947312413 + }, + { + "district": "AZ-09", + "average_household_income_change": 508.0174015085941, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8943184147686908, + "loser_percentage": 0.16214764452574437, + "no_change_percentage": 0.291321379072099, + "population": 1746339.202612584 + }, + { + "district": "AR-01", + "average_household_income_change": -886.7798349260943, + "relative_household_income_change": 0.9439036612373847, + "winner_percentage": 0.6402315474746479, + "loser_percentage": 0.44509850222390723, + "no_change_percentage": 0.7921069280361347, + "population": 1468015.1981863473 + }, + { + "district": "AR-02", + "average_household_income_change": 83.92200633709423, + "relative_household_income_change": 0.0, + "winner_percentage": 0.694104723835435, + "loser_percentage": 0.13173181234315456, + "no_change_percentage": 0.8864370231764853, + "population": 840645.7027551291 + }, + { + "district": "AR-03", + "average_household_income_change": -1035.0756287494755, + "relative_household_income_change": 0.0, + "winner_percentage": 0.559931781666012, + "loser_percentage": 0.6247386687904641, + "no_change_percentage": 0.919934108379581, + "population": 1923975.397019315 + }, + { + "district": "AR-04", + "average_household_income_change": 1041.737170195717, + "relative_household_income_change": 0.4176558064756952, + "winner_percentage": 0.09287217930675717, + "loser_percentage": 0.4047303109558881, + "no_change_percentage": 0.19899172023060585, + "population": 1113857.468180032 + }, + { + "district": "CA-01", + "average_household_income_change": 1920.0493803164393, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5633272391626525, + "loser_percentage": 0.017112014359287886, + "no_change_percentage": 0.7101313185350905, + "population": 1807612.6580073712 + }, + { + "district": "CA-02", + "average_household_income_change": 1190.9251224377977, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5349338144076542, + "loser_percentage": 0.7891161659884908, + "no_change_percentage": 0.379471337738705, + "population": 196587.60294740985 + }, + { + "district": "CA-03", + "average_household_income_change": -587.3232427555886, + "relative_household_income_change": 0.3121132200263218, + "winner_percentage": 0.49172302053949546, + "loser_percentage": 0.6111343735808167, + "no_change_percentage": 0.21191451351534507, + "population": 1198005.299279376 + }, + { + "district": "CA-04", + "average_household_income_change": 716.4863465475145, + "relative_household_income_change": 0.0, + "winner_percentage": 0.40688231107412964, + "loser_percentage": 0.32359317751493566, + "no_change_percentage": 0.6852808172439641, + "population": 283617.2683020191 + }, + { + "district": "CA-05", + "average_household_income_change": -1637.850054481115, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7596269971014065, + "loser_percentage": 0.6593931588317239, + "no_change_percentage": 0.934706631571226, + "population": 731208.9827030475 + }, + { + "district": "CA-06", + "average_household_income_change": -1881.0388383520524, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.48537066712505683, + "loser_percentage": 0.056666014100585804, + "no_change_percentage": 0.5942091818248112, + "population": 913910.6011144014 + }, + { + "district": "CA-07", + "average_household_income_change": 1537.1464588478023, + "relative_household_income_change": -0.0, + "winner_percentage": 0.007724418967058444, + "loser_percentage": 0.36031096701684984, + "no_change_percentage": 0.27969295418847406, + "population": 402670.9003519397 + }, + { + "district": "CA-08", + "average_household_income_change": -270.1820046885661, + "relative_household_income_change": -0.0, + "winner_percentage": 0.501034557094322, + "loser_percentage": 0.6980941469409674, + "no_change_percentage": 0.16234615818193077, + "population": 929260.2895029439 + }, + { + "district": "CA-09", + "average_household_income_change": 426.12124982149214, + "relative_household_income_change": -0.0, + "winner_percentage": 0.46017155393683884, + "loser_percentage": 0.7854393337281037, + "no_change_percentage": 0.04923914247505368, + "population": 1544560.3762195243 + }, + { + "district": "CA-10", + "average_household_income_change": -369.84459942607907, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5214095761879433, + "loser_percentage": 0.4399166678136097, + "no_change_percentage": 0.052247836980488294, + "population": 212633.49881591904 + }, + { + "district": "CA-11", + "average_household_income_change": 281.6506827110996, + "relative_household_income_change": 0.0, + "winner_percentage": 0.32226459514400096, + "loser_percentage": 0.49990018379463286, + "no_change_percentage": 0.3608400868813272, + "population": 614394.250070395 + }, + { + "district": "CA-12", + "average_household_income_change": -1456.3521600642657, + "relative_household_income_change": 0.2256742626495376, + "winner_percentage": 0.134494187324123, + "loser_percentage": 0.49460694973104014, + "no_change_percentage": 0.4290896639496672, + "population": 729571.0039977863 + }, + { + "district": "CA-13", + "average_household_income_change": -365.9226317365758, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3890777290830446, + "loser_percentage": 0.8730978577541066, + "no_change_percentage": 0.8972224563897103, + "population": 641934.4119458575 + }, + { + "district": "CA-14", + "average_household_income_change": -1496.642820259904, + "relative_household_income_change": 0.64094436192942, + "winner_percentage": 0.7960876508380115, + "loser_percentage": 0.3784190372558184, + "no_change_percentage": 0.4238353268638516, + "population": 1316958.22273491 + }, + { + "district": "CA-15", + "average_household_income_change": -640.6313059657078, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.38465022995985754, + "loser_percentage": 0.3995515988373419, + "no_change_percentage": 0.807182492186913, + "population": 339653.4441229799 + }, + { + "district": "CA-16", + "average_household_income_change": -1179.6415497140028, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5227510622715033, + "loser_percentage": 0.052923693272417416, + "no_change_percentage": 0.8353954006210182, + "population": 1747067.9393701318 + }, + { + "district": "CA-17", + "average_household_income_change": 1284.5950041837382, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7708073492145247, + "loser_percentage": 0.8589537357187278, + "no_change_percentage": 0.31103929007826037, + "population": 1745340.4845477268 + }, + { + "district": "CA-18", + "average_household_income_change": 996.0133397522277, + "relative_household_income_change": 0.8595428617640705, + "winner_percentage": 0.3084141838742047, + "loser_percentage": 0.012955567317396666, + "no_change_percentage": 0.17104269709747133, + "population": 326517.4209606851 + }, + { + "district": "CA-19", + "average_household_income_change": 863.3173647119321, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9815326644782767, + "loser_percentage": 0.7734976508614569, + "no_change_percentage": 0.546164705322463, + "population": 43443.39082932053 + }, + { + "district": "CA-20", + "average_household_income_change": 1643.211636016627, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5362462043780879, + "loser_percentage": 0.4484523393074651, + "no_change_percentage": 0.5275703669056998, + "population": 1302992.286184724 + }, + { + "district": "CA-21", + "average_household_income_change": 1202.8325019108204, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.46729097780509044, + "loser_percentage": 0.634066252711196, + "no_change_percentage": 0.3884791199329697, + "population": 829737.1997349036 + }, + { + "district": "CA-22", + "average_household_income_change": 66.62941062963812, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.020535945332515126, + "loser_percentage": 0.1550549007322337, + "no_change_percentage": 0.7571143646203627, + "population": 732875.0445007881 + }, + { + "district": "CA-23", + "average_household_income_change": -1587.8683043620656, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5487682328065107, + "loser_percentage": 0.9626410648799001, + "no_change_percentage": 0.3749766852995049, + "population": 143596.87028899114 + }, + { + "district": "CA-24", + "average_household_income_change": 1667.4429514798517, + "relative_household_income_change": -0.0, + "winner_percentage": 0.1081431390573181, + "loser_percentage": 0.08424475644417895, + "no_change_percentage": 0.05643113581015269, + "population": 16805.930927915848 + }, + { + "district": "CA-25", + "average_household_income_change": -1001.9043376747802, + "relative_household_income_change": 0.11317412321718456, + "winner_percentage": 0.6158127630016563, + "loser_percentage": 0.9656566882560107, + "no_change_percentage": 0.9605179179485582, + "population": 1493449.8495685991 + }, + { + "district": "CA-26", + "average_household_income_change": 1798.4606020938431, + "relative_household_income_change": -0.0, + "winner_percentage": 0.5870715001683793, + "loser_percentage": 0.8033802842755158, + "no_change_percentage": 0.6292175442471413, + "population": 921870.451521964 + }, + { + "district": "CA-27", + "average_household_income_change": -653.0806594029559, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.8001046273240519, + "loser_percentage": 0.39075730805975006, + "no_change_percentage": 0.6560261369262086, + "population": 674653.2909981434 + }, + { + "district": "CA-28", + "average_household_income_change": -1765.08349219778, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.14212089999151623, + "loser_percentage": 0.1224568853043495, + "no_change_percentage": 0.4855769544848747, + "population": 273398.3199164645 + }, + { + "district": "CA-29", + "average_household_income_change": -888.6998292046791, + "relative_household_income_change": 0.0, + "winner_percentage": 0.4824218931490709, + "loser_percentage": 0.7309984068579884, + "no_change_percentage": 0.8258033946553545, + "population": 1514596.9736237328 + }, + { + "district": "CA-30", + "average_household_income_change": -641.2184359421058, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.34254197921430385, + "loser_percentage": 0.8894237400921909, + "no_change_percentage": 0.6355235318535916, + "population": 1239103.128934888 + }, + { + "district": "CA-31", + "average_household_income_change": -1742.6211467233204, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6123986882300106, + "loser_percentage": 0.12683588146028657, + "no_change_percentage": 0.5409306560920599, + "population": 1904601.5656046001 + }, + { + "district": "CA-32", + "average_household_income_change": 979.7718608096169, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.9908157296652765, + "loser_percentage": 0.1192586923916098, + "no_change_percentage": 0.586893637715749, + "population": 984877.5889400134 + }, + { + "district": "CA-33", + "average_household_income_change": -549.301011917969, + "relative_household_income_change": 0.15602356639396198, + "winner_percentage": 0.5771809315400935, + "loser_percentage": 0.37839441251537, + "no_change_percentage": 0.3098382754201858, + "population": 709689.0495239014 + }, + { + "district": "CA-34", + "average_household_income_change": 1990.247485523149, + "relative_household_income_change": 0.33035619514819015, + "winner_percentage": 0.5982952077680709, + "loser_percentage": 0.0711720822230778, + "no_change_percentage": 0.6246853947621606, + "population": 1778686.913921832 + }, + { + "district": "CA-35", + "average_household_income_change": 1761.4016628961574, + "relative_household_income_change": 0.6205345270569311, + "winner_percentage": 0.34989600523094433, + "loser_percentage": 0.9379414777595734, + "no_change_percentage": 0.2968685503029024, + "population": 664761.1110170053 + }, + { + "district": "CA-36", + "average_household_income_change": 630.1992727016485, + "relative_household_income_change": 0.3972508761498267, + "winner_percentage": 0.3285972226227972, + "loser_percentage": 0.5373940175300163, + "no_change_percentage": 0.8349531491962504, + "population": 1050973.0294520468 + }, + { + "district": "CA-37", + "average_household_income_change": 1462.2350111671283, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.989549913187835, + "loser_percentage": 0.6737291687045169, + "no_change_percentage": 0.1715974717820693, + "population": 876524.1090736537 + }, + { + "district": "CA-38", + "average_household_income_change": -869.4282452112944, + "relative_household_income_change": -0.0, + "winner_percentage": 0.18770762679166875, + "loser_percentage": 0.2739483389115531, + "no_change_percentage": 0.12201087651538456, + "population": 424290.5019186154 + }, + { + "district": "CA-39", + "average_household_income_change": -1267.8231454240251, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.4584474945996394, + "loser_percentage": 0.9370204171804093, + "no_change_percentage": 0.3917403626543372, + "population": 930712.5864732913 + }, + { + "district": "CA-40", + "average_household_income_change": -562.3775341210342, + "relative_household_income_change": -0.0, + "winner_percentage": 0.23918997191071245, + "loser_percentage": 0.5260113238125497, + "no_change_percentage": 0.726498808599626, + "population": 1003126.5118603492 + }, + { + "district": "CA-41", + "average_household_income_change": 649.5470617907051, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3552770048304683, + "loser_percentage": 0.30657120150850037, + "no_change_percentage": 0.024657019036375294, + "population": 1780674.3483204115 + }, + { + "district": "CA-42", + "average_household_income_change": 1159.5063296568303, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.902446400231012, + "loser_percentage": 0.15150610126548647, + "no_change_percentage": 0.8366777637624933, + "population": 841703.408616225 + }, + { + "district": "CA-43", + "average_household_income_change": -742.9544276215356, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8309765238449147, + "loser_percentage": 0.14219214804793068, + "no_change_percentage": 0.4530294918780535, + "population": 1062304.3523327063 + }, + { + "district": "CA-44", + "average_household_income_change": -1017.3755240887896, + "relative_household_income_change": 0.21079003156596632, + "winner_percentage": 0.3796624281418225, + "loser_percentage": 0.4902777918239447, + "no_change_percentage": 0.6012503437187471, + "population": 1865959.885605579 + }, + { + "district": "CA-45", + "average_household_income_change": -1415.5658531587014, + "relative_household_income_change": 0.22807510480238247, + "winner_percentage": 0.9096491728454665, + "loser_percentage": 0.9317969080573828, + "no_change_percentage": 0.5283844979134728, + "population": 734097.1646511267 + }, + { + "district": "CA-46", + "average_household_income_change": -1245.3931830569275, + "relative_household_income_change": 0.910627262349237, + "winner_percentage": 0.10830967717910556, + "loser_percentage": 0.23905658916176586, + "no_change_percentage": 0.034282621247537115, + "population": 1092646.3590046442 + }, + { + "district": "CA-47", + "average_household_income_change": -225.7024315421745, + "relative_household_income_change": 0.4894877420169603, + "winner_percentage": 0.18535651239951723, + "loser_percentage": 0.837768991053808, + "no_change_percentage": 0.8392587441699838, + "population": 1288153.679040642 + }, + { + "district": "CA-48", + "average_household_income_change": -256.17032345475496, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.3840748545466999, + "loser_percentage": 0.841556693614585, + "no_change_percentage": 0.08498238084986265, + "population": 1092656.6410106996 + }, + { + "district": "CA-49", + "average_household_income_change": 1642.418280899994, + "relative_household_income_change": 0.45499419839442967, + "winner_percentage": 0.3996318991780353, + "loser_percentage": 0.00811283921494399, + "no_change_percentage": 0.5043576089482401, + "population": 830007.685726782 + }, + { + "district": "CA-50", + "average_household_income_change": -1434.719830025666, + "relative_household_income_change": 0.0, + "winner_percentage": 0.527748098058697, + "loser_percentage": 0.6223956665167715, + "no_change_percentage": 0.9904167675348735, + "population": 439734.6422016735 + }, + { + "district": "CA-51", + "average_household_income_change": -370.500854257362, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5242253175436219, + "loser_percentage": 0.7770367389890476, + "no_change_percentage": 0.67725832793819, + "population": 1738334.257700544 + }, + { + "district": "CA-52", + "average_household_income_change": 600.7036555406855, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3466247177277443, + "loser_percentage": 0.6541914874562558, + "no_change_percentage": 0.8251537907311142, + "population": 229628.011221144 + }, + { + "district": "CO-01", + "average_household_income_change": 1230.9077125999916, + "relative_household_income_change": -0.0, + "winner_percentage": 0.134106127817758, + "loser_percentage": 0.9661923609934115, + "no_change_percentage": 0.5327523065182042, + "population": 354098.5665959717 + }, + { + "district": "CO-02", + "average_household_income_change": 1306.482550421162, + "relative_household_income_change": -0.0, + "winner_percentage": 0.83221204878799, + "loser_percentage": 0.01950485751610953, + "no_change_percentage": 0.14456465305764543, + "population": 916157.3733286823 + }, + { + "district": "CO-03", + "average_household_income_change": -177.2015481856779, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8239581007636878, + "loser_percentage": 0.7509151623422733, + "no_change_percentage": 0.28541203910408686, + "population": 1511081.9751586048 + }, + { + "district": "CO-04", + "average_household_income_change": -265.2517793746414, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9316170783510357, + "loser_percentage": 0.3549960878338857, + "no_change_percentage": 0.2925758443326615, + "population": 1226064.510495367 + }, + { + "district": "CO-05", + "average_household_income_change": -512.3692031236847, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5662456475393759, + "loser_percentage": 0.5387945165631575, + "no_change_percentage": 0.5297979237287209, + "population": 1974517.8305325997 + }, + { + "district": "CO-06", + "average_household_income_change": 1619.0403702740514, + "relative_household_income_change": 0.2986875371903074, + "winner_percentage": 0.3151406134613959, + "loser_percentage": 0.9370034399542125, + "no_change_percentage": 0.05758881507922853, + "population": 636076.0962848675 + }, + { + "district": "CO-07", + "average_household_income_change": 1727.502898734149, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8844145545487769, + "loser_percentage": 0.08739250868322412, + "no_change_percentage": 0.9585700859571149, + "population": 1219250.9647520587 + }, + { + "district": "CO-08", + "average_household_income_change": 971.9081053382092, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9984189291863486, + "loser_percentage": 0.7792686301844646, + "no_change_percentage": 0.43725611432633693, + "population": 1822882.5088412128 + }, + { + "district": "CT-01", + "average_household_income_change": -733.4613699543544, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6602848372615883, + "loser_percentage": 0.48001860008713937, + "no_change_percentage": 0.2037742723546132, + "population": 438639.2924348621 + }, + { + "district": "CT-02", + "average_household_income_change": -1730.1936797458893, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.49352746379574675, + "loser_percentage": 0.8078484679181165, + "no_change_percentage": 0.16398700709907843, + "population": 404813.0910622814 + }, + { + "district": "CT-03", + "average_household_income_change": 1618.8458045691646, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7062977130428661, + "loser_percentage": 0.9848558991480393, + "no_change_percentage": 0.8456447938061156, + "population": 1355856.6590365525 + }, + { + "district": "CT-04", + "average_household_income_change": 361.44050009557515, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7172149080608088, + "loser_percentage": 0.98576729859634, + "no_change_percentage": 0.11465761960796195, + "population": 1300055.6738872197 + }, + { + "district": "CT-05", + "average_household_income_change": -1146.7188842352507, + "relative_household_income_change": 0.0, + "winner_percentage": 0.32802415989117717, + "loser_percentage": 0.987883939767343, + "no_change_percentage": 0.6841018084490844, + "population": 1033246.891060393 + }, + { + "district": "DE-01", + "average_household_income_change": -264.3914325460721, + "relative_household_income_change": -0.0, + "winner_percentage": 0.16889186920869248, + "loser_percentage": 0.8453652777791822, + "no_change_percentage": 0.5708336953080093, + "population": 358288.9534547711 + }, + { + "district": "DC-01", + "average_household_income_change": 308.59947634037167, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7232746655069926, + "loser_percentage": 0.626121078335344, + "no_change_percentage": 0.6628708181544503, + "population": 86669.61242482299 + }, + { + "district": "FL-01", + "average_household_income_change": -112.59017197951904, + "relative_household_income_change": -0.0, + "winner_percentage": 0.15922019566186107, + "loser_percentage": 0.08851487828435323, + "no_change_percentage": 0.9383871813145351, + "population": 1217564.895666008 + }, + { + "district": "FL-02", + "average_household_income_change": 773.8733757136331, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9285740031135886, + "loser_percentage": 0.9698970354758635, + "no_change_percentage": 0.7932385199454783, + "population": 1941585.7962708066 + }, + { + "district": "FL-03", + "average_household_income_change": 1574.4123838912917, + "relative_household_income_change": 0.5165645744583381, + "winner_percentage": 0.24757697079047203, + "loser_percentage": 0.8596539097339346, + "no_change_percentage": 0.260714167730137, + "population": 158112.12946288916 + }, + { + "district": "FL-04", + "average_household_income_change": -1348.045750010432, + "relative_household_income_change": 0.271839320032379, + "winner_percentage": 0.8783503813610679, + "loser_percentage": 0.6696400495589823, + "no_change_percentage": 0.42651065886229844, + "population": 82879.99705410458 + }, + { + "district": "FL-05", + "average_household_income_change": 1326.1189019811054, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6302331792941935, + "loser_percentage": 0.7683230628258404, + "no_change_percentage": 0.5568693144833027, + "population": 1344401.3008842464 + }, + { + "district": "FL-06", + "average_household_income_change": 689.2957361718439, + "relative_household_income_change": 0.0, + "winner_percentage": 0.734240342177587, + "loser_percentage": 0.14068781884842652, + "no_change_percentage": 0.2989575457439484, + "population": 415235.8634614184 + }, + { + "district": "FL-07", + "average_household_income_change": -244.4070681592101, + "relative_household_income_change": 0.9214691498053794, + "winner_percentage": 0.8158466151929633, + "loser_percentage": 0.5105868144012156, + "no_change_percentage": 0.7931260265065381, + "population": 138135.2066096766 + }, + { + "district": "FL-08", + "average_household_income_change": -1346.2538446209026, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9777477008113244, + "loser_percentage": 0.01067312919301644, + "no_change_percentage": 0.10743206798937299, + "population": 20020.80181001546 + }, + { + "district": "FL-09", + "average_household_income_change": 1039.002504622171, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7211832505316047, + "loser_percentage": 0.11887451199230736, + "no_change_percentage": 0.6579935892283897, + "population": 338129.9739872749 + }, + { + "district": "FL-10", + "average_household_income_change": 380.0954758283601, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8237264751259077, + "loser_percentage": 0.783237713622185, + "no_change_percentage": 0.37165318391889746, + "population": 560149.8769812064 + }, + { + "district": "FL-11", + "average_household_income_change": -552.922874730938, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.27346908472699316, + "loser_percentage": 0.6961161667540838, + "no_change_percentage": 0.885445907248799, + "population": 513503.2501471353 + }, + { + "district": "FL-12", + "average_household_income_change": 1073.0994188497257, + "relative_household_income_change": 0.0, + "winner_percentage": 0.11962075594871358, + "loser_percentage": 0.4001913220232566, + "no_change_percentage": 0.15816280416066675, + "population": 876361.5777005955 + }, + { + "district": "FL-13", + "average_household_income_change": -4.45563556553763, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8852236546294064, + "loser_percentage": 0.7652657751329798, + "no_change_percentage": 0.3866405389602191, + "population": 134669.5287914708 + }, + { + "district": "FL-14", + "average_household_income_change": 1561.1667301797008, + "relative_household_income_change": 0.9666775774731736, + "winner_percentage": 0.4453773809085094, + "loser_percentage": 0.6875953301020271, + "no_change_percentage": 0.7446337852288136, + "population": 1652638.352648129 + }, + { + "district": "FL-15", + "average_household_income_change": -750.7834866353904, + "relative_household_income_change": -0.0, + "winner_percentage": 0.047040171938993014, + "loser_percentage": 0.873520383209386, + "no_change_percentage": 0.31531043718411744, + "population": 784895.5178367962 + }, + { + "district": "FL-16", + "average_household_income_change": -748.468167882268, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9217627058581727, + "loser_percentage": 0.8034127881093197, + "no_change_percentage": 0.41369178988727084, + "population": 157895.94684732158 + }, + { + "district": "FL-17", + "average_household_income_change": -683.6432519853073, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.4622665515230927, + "loser_percentage": 0.35546866115783415, + "no_change_percentage": 0.309973778859606, + "population": 687355.3547359019 + }, + { + "district": "FL-18", + "average_household_income_change": -1567.9375428973196, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.19496632411823256, + "loser_percentage": 0.414156400672014, + "no_change_percentage": 0.2066105747818574, + "population": 1561995.0401522873 + }, + { + "district": "FL-19", + "average_household_income_change": 1929.3050462902315, + "relative_household_income_change": 0.0, + "winner_percentage": 0.7384347309102355, + "loser_percentage": 0.22676891304456437, + "no_change_percentage": 0.10882645138615277, + "population": 1968314.4360111654 + }, + { + "district": "FL-20", + "average_household_income_change": 738.0566493055829, + "relative_household_income_change": -0.0, + "winner_percentage": 0.02162699991270478, + "loser_percentage": 0.3689932192516341, + "no_change_percentage": 0.8564042008511202, + "population": 735483.202492574 + }, + { + "district": "FL-21", + "average_household_income_change": -1311.1095736131308, + "relative_household_income_change": 0.0, + "winner_percentage": 0.09085898731234521, + "loser_percentage": 0.0809758001730293, + "no_change_percentage": 0.7504106876231031, + "population": 704384.8907859478 + }, + { + "district": "FL-22", + "average_household_income_change": -88.06165501404539, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7200803570245979, + "loser_percentage": 0.3373593868866339, + "no_change_percentage": 0.10096316178102516, + "population": 1477463.1731296738 + }, + { + "district": "FL-23", + "average_household_income_change": -779.9565957539163, + "relative_household_income_change": 0.0, + "winner_percentage": 0.02821889127456545, + "loser_percentage": 0.057384409391906255, + "no_change_percentage": 0.5136530379520321, + "population": 481440.7194824504 + }, + { + "district": "FL-24", + "average_household_income_change": -734.1701476158872, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.16571489383564586, + "loser_percentage": 0.42181229313837243, + "no_change_percentage": 0.4148364652968418, + "population": 144671.57912561274 + }, + { + "district": "FL-25", + "average_household_income_change": 1439.0123523600278, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9847264132526234, + "loser_percentage": 0.0600499173398964, + "no_change_percentage": 0.8935094299991524, + "population": 1927505.4994544394 + }, + { + "district": "FL-26", + "average_household_income_change": 1112.7518063829727, + "relative_household_income_change": 0.5800136872358187, + "winner_percentage": 0.4817964173700803, + "loser_percentage": 0.853193681149301, + "no_change_percentage": 0.578356603109671, + "population": 1094981.2602417956 + }, + { + "district": "FL-27", + "average_household_income_change": -379.87702691171154, + "relative_household_income_change": 0.6281933650116516, + "winner_percentage": 0.00751791104830124, + "loser_percentage": 0.46553684601857914, + "no_change_percentage": 0.9720088938614151, + "population": 342727.5228511398 + }, + { + "district": "FL-28", + "average_household_income_change": 1893.5463200119464, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5260196223449102, + "loser_percentage": 0.7269352285508964, + "no_change_percentage": 0.07248637234593225, + "population": 293197.241799674 + }, + { + "district": "GA-01", + "average_household_income_change": -1269.247954243442, + "relative_household_income_change": -0.0, + "winner_percentage": 0.4930499461266703, + "loser_percentage": 0.8119924059635303, + "no_change_percentage": 0.6451811151764774, + "population": 1743797.409884929 + }, + { + "district": "GA-02", + "average_household_income_change": 1944.5634662257744, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.1637474833297795, + "loser_percentage": 0.9101331135824555, + "no_change_percentage": 0.8924927066636925, + "population": 1809722.0657042577 + }, + { + "district": "GA-03", + "average_household_income_change": 200.13903071021844, + "relative_household_income_change": -0.0, + "winner_percentage": 0.92450506566269, + "loser_percentage": 0.9839192343928617, + "no_change_percentage": 0.021287694151356895, + "population": 1855389.9606955382 + }, + { + "district": "GA-04", + "average_household_income_change": -1586.2380303510556, + "relative_household_income_change": 0.948824094584967, + "winner_percentage": 0.25648213147860555, + "loser_percentage": 0.7153116942268624, + "no_change_percentage": 0.5629683530630664, + "population": 904596.870184385 + }, + { + "district": "GA-05", + "average_household_income_change": 507.64950923949795, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6951005281807577, + "loser_percentage": 0.005613476946991103, + "no_change_percentage": 0.6392093871665209, + "population": 1409873.9105898377 + }, + { + "district": "GA-06", + "average_household_income_change": 1466.3481151392882, + "relative_household_income_change": 0.5359969399876601, + "winner_percentage": 0.03130806345470605, + "loser_percentage": 0.7646033703967934, + "no_change_percentage": 0.5326925731859895, + "population": 1833778.2332628572 + }, + { + "district": "GA-07", + "average_household_income_change": -1894.2026823531771, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.3840918648059817, + "loser_percentage": 0.6223416845764114, + "no_change_percentage": 0.029831920661660516, + "population": 893996.0575827288 + }, + { + "district": "GA-08", + "average_household_income_change": 1193.489638982387, + "relative_household_income_change": -0.0, + "winner_percentage": 0.3496351693543115, + "loser_percentage": 0.1995086740568508, + "no_change_percentage": 0.3990670077832993, + "population": 538851.1072021148 + }, + { + "district": "GA-09", + "average_household_income_change": 1705.0355266840365, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5578041016153189, + "loser_percentage": 0.6860369771120276, + "no_change_percentage": 0.20796258986036864, + "population": 489292.4891201653 + }, + { + "district": "GA-10", + "average_household_income_change": 1696.1171971869958, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.6026795763876366, + "loser_percentage": 0.8750851361290138, + "no_change_percentage": 0.973836406000621, + "population": 385172.0208488181 + }, + { + "district": "GA-11", + "average_household_income_change": -1363.7847713726296, + "relative_household_income_change": 0.0, + "winner_percentage": 0.09165394883261968, + "loser_percentage": 0.5740776221964935, + "no_change_percentage": 0.43305379741901473, + "population": 1511611.7264460314 + }, + { + "district": "GA-12", + "average_household_income_change": -495.6398197756493, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5992307575882658, + "loser_percentage": 0.5502319596449463, + "no_change_percentage": 0.6098730762934008, + "population": 866752.5806505374 + }, + { + "district": "GA-13", + "average_household_income_change": 715.2380413264541, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.4474572622702626, + "loser_percentage": 0.6793474774468349, + "no_change_percentage": 0.8072004938963492, + "population": 571649.1788765253 + }, + { + "district": "GA-14", + "average_household_income_change": -1179.6946454105735, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.6158108831536124, + "loser_percentage": 0.8145916322268135, + "no_change_percentage": 0.08660852191797641, + "population": 1941243.7141759186 + }, + { + "district": "HI-01", + "average_household_income_change": 1422.2919651118193, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.30675674401692676, + "loser_percentage": 0.1487782451775993, + "no_change_percentage": 0.0101814912137137, + "population": 1922514.898091813 + }, + { + "district": "HI-02", + "average_household_income_change": 1561.1958715981639, + "relative_household_income_change": 0.4462215807781249, + "winner_percentage": 0.6107603943100495, + "loser_percentage": 0.043475700084457114, + "no_change_percentage": 0.3460879482288295, + "population": 1267120.312960441 + }, + { + "district": "ID-01", + "average_household_income_change": -1958.8201887857224, + "relative_household_income_change": 0.2775124877026566, + "winner_percentage": 0.9126213405124977, + "loser_percentage": 0.973963041712753, + "no_change_percentage": 0.49025534971259277, + "population": 269400.4299547344 + }, + { + "district": "ID-02", + "average_household_income_change": -39.28612547861299, + "relative_household_income_change": 0.9944174108151674, + "winner_percentage": 0.8003806250954807, + "loser_percentage": 0.9773639957690703, + "no_change_percentage": 0.10415293898520461, + "population": 909449.6274312991 + }, + { + "district": "IL-01", + "average_household_income_change": -742.5521383258542, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.22288892351390233, + "loser_percentage": 0.9315611406686448, + "no_change_percentage": 0.5668338419436574, + "population": 1720931.9288024302 + }, + { + "district": "IL-02", + "average_household_income_change": 831.6869640152884, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2967933749601812, + "loser_percentage": 0.13571963406036935, + "no_change_percentage": 0.832357673164309, + "population": 791041.6696110559 + }, + { + "district": "IL-03", + "average_household_income_change": -797.2436956244903, + "relative_household_income_change": -0.0, + "winner_percentage": 0.06490534314116103, + "loser_percentage": 0.7519750388599092, + "no_change_percentage": 0.06660901599496571, + "population": 377217.7807398747 + }, + { + "district": "IL-04", + "average_household_income_change": -1428.9377996776147, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.12265122169157006, + "loser_percentage": 0.8518052170670675, + "no_change_percentage": 0.11059112619811418, + "population": 1160167.199854474 + }, + { + "district": "IL-05", + "average_household_income_change": 1836.659970776044, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5523320784858717, + "loser_percentage": 0.14139887426910824, + "no_change_percentage": 0.24547275396857648, + "population": 1925810.3730733125 + }, + { + "district": "IL-06", + "average_household_income_change": -1189.7986860261929, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.17377762261537033, + "loser_percentage": 0.6797731681642427, + "no_change_percentage": 0.8520375232552789, + "population": 1203109.6925865589 + }, + { + "district": "IL-07", + "average_household_income_change": 1577.7835323325617, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9000013013614059, + "loser_percentage": 0.45935866427942185, + "no_change_percentage": 0.4947185302278443, + "population": 539344.8345100002 + }, + { + "district": "IL-08", + "average_household_income_change": 1492.6037455260212, + "relative_household_income_change": 0.8647602261811477, + "winner_percentage": 0.19806932299158597, + "loser_percentage": 0.741758214164136, + "no_change_percentage": 0.1973706976064603, + "population": 1242894.371215378 + }, + { + "district": "IL-09", + "average_household_income_change": -1957.9202231447507, + "relative_household_income_change": 0.5767955919563806, + "winner_percentage": 0.5049045593188333, + "loser_percentage": 0.627906677936699, + "no_change_percentage": 0.11768377169617017, + "population": 915472.239240144 + }, + { + "district": "IL-10", + "average_household_income_change": 1134.101038401237, + "relative_household_income_change": 0.37314553166486586, + "winner_percentage": 0.5989421207757561, + "loser_percentage": 0.8079306184127111, + "no_change_percentage": 0.7389160850591271, + "population": 609919.9286527971 + }, + { + "district": "IL-11", + "average_household_income_change": 1276.4461081683498, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.722586100687897, + "loser_percentage": 0.4047369019257887, + "no_change_percentage": 0.4875187991267902, + "population": 736190.8603408891 + }, + { + "district": "IL-12", + "average_household_income_change": -532.8141313249755, + "relative_household_income_change": 0.7093294285856272, + "winner_percentage": 0.34796286831592704, + "loser_percentage": 0.8753306159584747, + "no_change_percentage": 0.012947354199790873, + "population": 1882250.0880713076 + }, + { + "district": "IL-13", + "average_household_income_change": 299.88269945536985, + "relative_household_income_change": 0.0, + "winner_percentage": 0.3439412827291133, + "loser_percentage": 0.5127197856449134, + "no_change_percentage": 0.1306229711870046, + "population": 1296650.8797191768 + }, + { + "district": "IL-14", + "average_household_income_change": 1131.5001533207214, + "relative_household_income_change": -0.0, + "winner_percentage": 0.14414609199940498, + "loser_percentage": 0.6757638724127316, + "no_change_percentage": 0.7381168627632002, + "population": 1569660.962972421 + }, + { + "district": "IL-15", + "average_household_income_change": 30.82038680684196, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8985201664466087, + "loser_percentage": 0.7166345684965276, + "no_change_percentage": 0.2767519988734092, + "population": 814190.5964516615 + }, + { + "district": "IL-16", + "average_household_income_change": -1653.123022446867, + "relative_household_income_change": 0.22451665861812453, + "winner_percentage": 0.8656914299923075, + "loser_percentage": 0.843313160737658, + "no_change_percentage": 0.6805555743171952, + "population": 995973.5312472819 + }, + { + "district": "IL-17", + "average_household_income_change": 953.972640733002, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.4786241894978204, + "loser_percentage": 0.9012826937760073, + "no_change_percentage": 0.3547081545876839, + "population": 1402391.0197606967 + }, + { + "district": "IN-01", + "average_household_income_change": 70.96249182250335, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5604255503804655, + "loser_percentage": 0.15983701084647672, + "no_change_percentage": 0.8607033798105205, + "population": 859789.5127165096 + }, + { + "district": "IN-02", + "average_household_income_change": 168.1599924637851, + "relative_household_income_change": -0.0, + "winner_percentage": 0.4693498350379831, + "loser_percentage": 0.8680105613452282, + "no_change_percentage": 0.5706545236591403, + "population": 767782.6765989842 + }, + { + "district": "IN-03", + "average_household_income_change": 1404.6936404954358, + "relative_household_income_change": 0.9259161295515774, + "winner_percentage": 0.4652944676602273, + "loser_percentage": 0.729817009920871, + "no_change_percentage": 0.9303847318216023, + "population": 1215597.6254804253 + }, + { + "district": "IN-04", + "average_household_income_change": 706.652772622924, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5740192806288786, + "loser_percentage": 0.7826523228155402, + "no_change_percentage": 0.954839434574745, + "population": 242552.16058150108 + }, + { + "district": "IN-05", + "average_household_income_change": 1045.6028209384463, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.38949969665516526, + "loser_percentage": 0.6257720740999431, + "no_change_percentage": 0.9185202262992922, + "population": 1414403.4565510713 + }, + { + "district": "IN-06", + "average_household_income_change": -1680.3948280650854, + "relative_household_income_change": -0.0, + "winner_percentage": 0.5152840905248333, + "loser_percentage": 0.682781489402838, + "no_change_percentage": 0.9056795948117755, + "population": 1328114.3576752637 + }, + { + "district": "IN-07", + "average_household_income_change": -1593.6092126578392, + "relative_household_income_change": 0.9300165882962277, + "winner_percentage": 0.8071593733641752, + "loser_percentage": 0.900119994221593, + "no_change_percentage": 0.8354954367787131, + "population": 1367430.5327072756 + }, + { + "district": "IN-08", + "average_household_income_change": 1068.5852631784965, + "relative_household_income_change": 0.0, + "winner_percentage": 0.8204132946245839, + "loser_percentage": 0.3608426792138256, + "no_change_percentage": 0.18788889430727695, + "population": 21498.863981323782 + }, + { + "district": "IN-09", + "average_household_income_change": 776.9347282683157, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6616872981421206, + "loser_percentage": 0.030077650802229683, + "no_change_percentage": 0.34472141995243333, + "population": 1302732.5334541004 + }, + { + "district": "IA-01", + "average_household_income_change": -172.90289721353201, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.9984860116826633, + "loser_percentage": 0.4338107893045394, + "no_change_percentage": 0.8739248717621387, + "population": 503227.16508634866 + }, + { + "district": "IA-02", + "average_household_income_change": -1394.6923023364013, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8106361295631114, + "loser_percentage": 0.626002225091513, + "no_change_percentage": 0.8134850179621343, + "population": 784835.4653311565 + }, + { + "district": "IA-03", + "average_household_income_change": 1713.4816470394996, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9076181488172578, + "loser_percentage": 0.36436919530845213, + "no_change_percentage": 0.33861984777998977, + "population": 1784915.3228063858 + }, + { + "district": "IA-04", + "average_household_income_change": 1302.92709206923, + "relative_household_income_change": 0.5909766192015912, + "winner_percentage": 0.6048621685192132, + "loser_percentage": 0.37038998722410765, + "no_change_percentage": 0.21580580665448057, + "population": 521908.5145946276 + }, + { + "district": "KS-01", + "average_household_income_change": -801.536463991424, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.15779600302905106, + "loser_percentage": 0.3275096345694931, + "no_change_percentage": 0.7700970063040022, + "population": 1158917.6367092847 + }, + { + "district": "KS-02", + "average_household_income_change": -1467.5486906880017, + "relative_household_income_change": 0.0, + "winner_percentage": 0.07794971326549949, + "loser_percentage": 0.3659647085800364, + "no_change_percentage": 0.6697767196678369, + "population": 1180315.3254442988 + }, + { + "district": "KS-03", + "average_household_income_change": -998.5389142285652, + "relative_household_income_change": 0.96644400131702, + "winner_percentage": 0.2199866045393546, + "loser_percentage": 0.7212089046789031, + "no_change_percentage": 0.5273740102688681, + "population": 1300551.5281737293 + }, + { + "district": "KS-04", + "average_household_income_change": 59.76139904572708, + "relative_household_income_change": 0.0731691308450253, + "winner_percentage": 0.4230926063914482, + "loser_percentage": 0.5564120081519053, + "no_change_percentage": 0.16805565313238235, + "population": 1825669.2040313217 + }, + { + "district": "KY-01", + "average_household_income_change": -439.46558083917967, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5315916588112487, + "loser_percentage": 0.4297116811134062, + "no_change_percentage": 0.7779802329532324, + "population": 1241387.4136178733 + }, + { + "district": "KY-02", + "average_household_income_change": -1958.2920564168428, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3102185117449269, + "loser_percentage": 0.2601416687778113, + "no_change_percentage": 0.7174637011128064, + "population": 103222.15098348679 + }, + { + "district": "KY-03", + "average_household_income_change": 1577.5956112680167, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5509390406133198, + "loser_percentage": 0.41939119315240914, + "no_change_percentage": 0.40112867829271226, + "population": 1293036.2265681778 + }, + { + "district": "KY-04", + "average_household_income_change": 597.7932019967614, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5566798264100037, + "loser_percentage": 0.49443671187899363, + "no_change_percentage": 0.8664142094952593, + "population": 159073.7403591771 + }, + { + "district": "KY-05", + "average_household_income_change": -627.7167955459339, + "relative_household_income_change": 0.0, + "winner_percentage": 0.0639402084248164, + "loser_percentage": 0.44643648536776714, + "no_change_percentage": 0.9930082783315363, + "population": 1958931.4244845738 + }, + { + "district": "KY-06", + "average_household_income_change": -727.5992738181895, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5726424832986132, + "loser_percentage": 0.862548842259578, + "no_change_percentage": 0.7502947980563558, + "population": 949144.9444322865 + }, + { + "district": "LA-01", + "average_household_income_change": -1364.3590971359308, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.476438483581604, + "loser_percentage": 0.9005795074767224, + "no_change_percentage": 0.7464015955534695, + "population": 345043.6507790921 + }, + { + "district": "LA-02", + "average_household_income_change": -1903.5085270057257, + "relative_household_income_change": 0.1703089988165779, + "winner_percentage": 0.7175414719440296, + "loser_percentage": 0.23317379559193963, + "no_change_percentage": 0.3609540565462225, + "population": 1383561.5821714178 + }, + { + "district": "LA-03", + "average_household_income_change": -1629.2180327406131, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.22703210958810316, + "loser_percentage": 0.7907577314390017, + "no_change_percentage": 0.2606935385885101, + "population": 729464.6838370862 + }, + { + "district": "LA-04", + "average_household_income_change": -383.22317966654043, + "relative_household_income_change": 0.0, + "winner_percentage": 0.18074902751456967, + "loser_percentage": 0.2926629118216396, + "no_change_percentage": 0.7159850595200199, + "population": 400149.26426947257 + }, + { + "district": "LA-05", + "average_household_income_change": 313.5519251237011, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6867985570598312, + "loser_percentage": 0.14598790261955452, + "no_change_percentage": 0.19367174558790679, + "population": 1818327.8344902145 + }, + { + "district": "LA-06", + "average_household_income_change": -1122.701598229701, + "relative_household_income_change": 0.0, + "winner_percentage": 0.41670316989239153, + "loser_percentage": 0.5276358782343809, + "no_change_percentage": 0.10157188800946892, + "population": 1340155.2865167146 + }, + { + "district": "ME-01", + "average_household_income_change": 1921.3484261844283, + "relative_household_income_change": 0.15347762030365875, + "winner_percentage": 0.4775584954966925, + "loser_percentage": 0.5635039159944188, + "no_change_percentage": 0.9200100413801342, + "population": 1005730.3873189946 + }, + { + "district": "ME-02", + "average_household_income_change": 1198.2131676192255, + "relative_household_income_change": 0.8484137820647389, + "winner_percentage": 0.9766204344664193, + "loser_percentage": 0.2650886493790763, + "no_change_percentage": 0.10344663845239477, + "population": 1863014.0881681717 + }, + { + "district": "MD-01", + "average_household_income_change": 1702.901383133684, + "relative_household_income_change": 0.2143028894383544, + "winner_percentage": 0.18688458854843337, + "loser_percentage": 0.5461223819936809, + "no_change_percentage": 0.34596273557092383, + "population": 844926.0891118036 + }, + { + "district": "MD-02", + "average_household_income_change": -446.36186571537183, + "relative_household_income_change": 0.0, + "winner_percentage": 0.49660729888128285, + "loser_percentage": 0.862475039076589, + "no_change_percentage": 0.4454012730312159, + "population": 542673.1678530314 + }, + { + "district": "MD-03", + "average_household_income_change": -426.33497306834033, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7712136782106079, + "loser_percentage": 0.4604555382163549, + "no_change_percentage": 0.6293759911041594, + "population": 1388690.6206276682 + }, + { + "district": "MD-04", + "average_household_income_change": -1285.7942373542714, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6450332932660897, + "loser_percentage": 0.10992059831565892, + "no_change_percentage": 0.10908133197881842, + "population": 1617344.7215995933 + }, + { + "district": "MD-05", + "average_household_income_change": 829.1519073807833, + "relative_household_income_change": 0.6244743874457772, + "winner_percentage": 0.11104437970100578, + "loser_percentage": 0.6871799963497981, + "no_change_percentage": 0.10347992565513853, + "population": 34837.83938187687 + }, + { + "district": "MD-06", + "average_household_income_change": -1250.5060513492663, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.2945014209753922, + "loser_percentage": 0.28703695418546127, + "no_change_percentage": 0.5723026316816926, + "population": 1279464.5968338784 + }, + { + "district": "MD-07", + "average_household_income_change": -1173.7953960926784, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.3077889316540633, + "loser_percentage": 0.14604870787099422, + "no_change_percentage": 0.5784019372409128, + "population": 835102.5847782891 + }, + { + "district": "MD-08", + "average_household_income_change": 958.4059995627747, + "relative_household_income_change": 0.1797136586040724, + "winner_percentage": 0.5783591341667679, + "loser_percentage": 0.877472856433452, + "no_change_percentage": 0.5081306575888774, + "population": 908920.711186995 + }, + { + "district": "MA-01", + "average_household_income_change": 1416.8346562987865, + "relative_household_income_change": 0.0, + "winner_percentage": 0.3141124548700813, + "loser_percentage": 0.5490960771595501, + "no_change_percentage": 0.1370813477802525, + "population": 1269028.186098155 + }, + { + "district": "MA-02", + "average_household_income_change": -503.1472085947617, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.741863264029681, + "loser_percentage": 0.4452677828502838, + "no_change_percentage": 0.25980312603028133, + "population": 1019406.9456735733 + }, + { + "district": "MA-03", + "average_household_income_change": 593.7196781514027, + "relative_household_income_change": 0.0, + "winner_percentage": 0.0010621942626991565, + "loser_percentage": 0.1586070962209688, + "no_change_percentage": 0.9960816450557308, + "population": 1092970.6778509466 + }, + { + "district": "MA-04", + "average_household_income_change": 54.53362383745875, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8263750701371254, + "loser_percentage": 0.31033436314622787, + "no_change_percentage": 0.27119567144427503, + "population": 146002.38956846413 + }, + { + "district": "MA-05", + "average_household_income_change": -775.4813945067278, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.17269551015352502, + "loser_percentage": 0.33134070111743685, + "no_change_percentage": 0.6644311862866258, + "population": 1296533.775787876 + }, + { + "district": "MA-06", + "average_household_income_change": -1221.0022519637093, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.13285402478580643, + "loser_percentage": 0.1485201336102424, + "no_change_percentage": 0.6721815076145037, + "population": 100614.97459270433 + }, + { + "district": "MA-07", + "average_household_income_change": -87.21918760309944, + "relative_household_income_change": 0.863282508684126, + "winner_percentage": 0.6083553079922789, + "loser_percentage": 0.5994646976434661, + "no_change_percentage": 0.12318961270575113, + "population": 1981178.1976581682 + }, + { + "district": "MA-08", + "average_household_income_change": 1861.3530810064406, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8005235502084675, + "loser_percentage": 0.051754679795765646, + "no_change_percentage": 0.806748361608218, + "population": 485845.9904615471 + }, + { + "district": "MA-09", + "average_household_income_change": -1493.1860660360483, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.19562419841567757, + "loser_percentage": 0.5421273722207332, + "no_change_percentage": 0.378957135325633, + "population": 223702.1652700539 + }, + { + "district": "MI-01", + "average_household_income_change": 747.6924636515037, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.332978525789828, + "loser_percentage": 0.02250058685817169, + "no_change_percentage": 0.0699379879127281, + "population": 1069746.452205889 + }, + { + "district": "MI-02", + "average_household_income_change": -582.2341453743993, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.809417093226375, + "loser_percentage": 0.9572113987884276, + "no_change_percentage": 0.7663004546750843, + "population": 304063.51036169555 + }, + { + "district": "MI-03", + "average_household_income_change": 539.0419864831002, + "relative_household_income_change": -0.0, + "winner_percentage": 0.44319499146694996, + "loser_percentage": 0.7559869300969424, + "no_change_percentage": 0.6467521336418156, + "population": 555912.7641956314 + }, + { + "district": "MI-04", + "average_household_income_change": -598.8011472955503, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5453152034206704, + "loser_percentage": 0.9099431686557429, + "no_change_percentage": 0.6870091744991247, + "population": 1388686.9483119224 + }, + { + "district": "MI-05", + "average_household_income_change": 1688.969341646402, + "relative_household_income_change": 0.012013326021297233, + "winner_percentage": 0.738762866655325, + "loser_percentage": 0.06721378517513077, + "no_change_percentage": 0.6932132528410323, + "population": 751136.6903781283 + }, + { + "district": "MI-06", + "average_household_income_change": -1481.2829169075976, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3439870287900032, + "loser_percentage": 0.11495835563060186, + "no_change_percentage": 0.1642953836325286, + "population": 1410030.2948224256 + }, + { + "district": "MI-07", + "average_household_income_change": 1298.3306232899383, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7475569280994253, + "loser_percentage": 0.1522308590335446, + "no_change_percentage": 0.2738319866907999, + "population": 1661527.2188666146 + }, + { + "district": "MI-08", + "average_household_income_change": 98.35919936821958, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8087863580886516, + "loser_percentage": 0.17937380845130346, + "no_change_percentage": 0.6067885223130947, + "population": 878.3177274980117 + }, + { + "district": "MI-09", + "average_household_income_change": -1161.4298905365165, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6298236728074138, + "loser_percentage": 0.6801065870092902, + "no_change_percentage": 0.2817643398509323, + "population": 753184.912590639 + }, + { + "district": "MI-10", + "average_household_income_change": 1558.124809115705, + "relative_household_income_change": 0.2627878996555487, + "winner_percentage": 0.5572251010462818, + "loser_percentage": 0.17597908543615892, + "no_change_percentage": 0.5817198682065653, + "population": 1091698.9193075108 + }, + { + "district": "MI-11", + "average_household_income_change": 599.9241748259441, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.08410093298080046, + "loser_percentage": 0.9434356805492797, + "no_change_percentage": 0.8972368101002257, + "population": 1529510.4841412515 + }, + { + "district": "MI-12", + "average_household_income_change": 43.70565216707334, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7824540201953049, + "loser_percentage": 0.5640828896294948, + "no_change_percentage": 0.6404440150803293, + "population": 556875.7039056781 + }, + { + "district": "MI-13", + "average_household_income_change": -430.5457952783849, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.21234343940768685, + "loser_percentage": 0.4275690963090871, + "no_change_percentage": 0.2916862542675427, + "population": 1179000.7744625115 + }, + { + "district": "MN-01", + "average_household_income_change": -1049.7138824796225, + "relative_household_income_change": 0.3727512718102719, + "winner_percentage": 0.4770714577747178, + "loser_percentage": 0.3914468962844778, + "no_change_percentage": 0.1778027569550269, + "population": 1330527.5413455432 + }, + { + "district": "MN-02", + "average_household_income_change": -1474.8076265750228, + "relative_household_income_change": 0.9289048142758826, + "winner_percentage": 0.13997488521748647, + "loser_percentage": 0.5799255484506891, + "no_change_percentage": 0.009217647181251842, + "population": 63614.29054890922 + }, + { + "district": "MN-03", + "average_household_income_change": 478.6972643757057, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8834709327868909, + "loser_percentage": 0.9764696126405257, + "no_change_percentage": 0.06808872782490405, + "population": 279478.5977209589 + }, + { + "district": "MN-04", + "average_household_income_change": -758.4000623220745, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9793788964482745, + "loser_percentage": 0.9223794630483954, + "no_change_percentage": 0.9913210056905115, + "population": 1236882.1844880541 + }, + { + "district": "MN-05", + "average_household_income_change": -1276.0615571959931, + "relative_household_income_change": 0.0, + "winner_percentage": 0.4391093208196486, + "loser_percentage": 0.7049212714324123, + "no_change_percentage": 0.2091770500168495, + "population": 1138057.030369344 + }, + { + "district": "MN-06", + "average_household_income_change": 1419.3816667809144, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.14382038488848137, + "loser_percentage": 0.6655023332981367, + "no_change_percentage": 0.041662734674265844, + "population": 487674.8074334767 + }, + { + "district": "MN-07", + "average_household_income_change": -1712.1659206986233, + "relative_household_income_change": 0.19708362157980042, + "winner_percentage": 0.3506818697740155, + "loser_percentage": 0.323962746659895, + "no_change_percentage": 0.4885777377608531, + "population": 360611.0401030129 + }, + { + "district": "MN-08", + "average_household_income_change": -901.656897741046, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.66631940230475, + "loser_percentage": 0.7223543911582926, + "no_change_percentage": 0.27213596933584105, + "population": 561758.3054228415 + }, + { + "district": "MS-01", + "average_household_income_change": -255.90344511944662, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8661702253422987, + "loser_percentage": 0.66118515674882, + "no_change_percentage": 0.048966995321201856, + "population": 1595101.0802975115 + }, + { + "district": "MS-02", + "average_household_income_change": 1072.3170739476213, + "relative_household_income_change": -0.0, + "winner_percentage": 0.734126910069098, + "loser_percentage": 0.2760096221893993, + "no_change_percentage": 0.68582587357717, + "population": 1936414.229907305 + }, + { + "district": "MS-03", + "average_household_income_change": 298.2937768552656, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5634955646257539, + "loser_percentage": 0.2557204553896971, + "no_change_percentage": 0.5532640828786518, + "population": 852166.905599482 + }, + { + "district": "MS-04", + "average_household_income_change": -1123.6494666458668, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.4850530850613204, + "loser_percentage": 0.542074369202462, + "no_change_percentage": 0.70572458521411, + "population": 216115.7074154712 + }, + { + "district": "MO-01", + "average_household_income_change": 40.81526602761221, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.07891563444520278, + "loser_percentage": 0.9464586690534529, + "no_change_percentage": 0.028449114629045757, + "population": 615711.0223877344 + }, + { + "district": "MO-02", + "average_household_income_change": -657.1049821804115, + "relative_household_income_change": 0.0, + "winner_percentage": 0.014130832310066177, + "loser_percentage": 0.5988327326146267, + "no_change_percentage": 0.2385767692206523, + "population": 1598205.7162534988 + }, + { + "district": "MO-03", + "average_household_income_change": -53.7815958697565, + "relative_household_income_change": 0.3229614670597981, + "winner_percentage": 0.36156747690200897, + "loser_percentage": 0.6781971292751447, + "no_change_percentage": 0.942078824705612, + "population": 1269408.8987946059 + }, + { + "district": "MO-04", + "average_household_income_change": -671.5295872504687, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6979644825140945, + "loser_percentage": 0.601781099012347, + "no_change_percentage": 0.572364627309296, + "population": 907118.6239947318 + }, + { + "district": "MO-05", + "average_household_income_change": -530.1692420025117, + "relative_household_income_change": -0.0, + "winner_percentage": 0.23027025708676785, + "loser_percentage": 0.4051771799769186, + "no_change_percentage": 0.887996694797536, + "population": 49155.26876678865 + }, + { + "district": "MO-06", + "average_household_income_change": -586.0399057701087, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7738649989297562, + "loser_percentage": 0.8679764267682314, + "no_change_percentage": 0.7292179446919571, + "population": 1122912.1066235972 + }, + { + "district": "MO-07", + "average_household_income_change": 1964.898418684791, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9042759026361998, + "loser_percentage": 0.36455736475779155, + "no_change_percentage": 0.8489084702077566, + "population": 379743.7160746093 + }, + { + "district": "MO-08", + "average_household_income_change": 336.5603570250859, + "relative_household_income_change": -0.0, + "winner_percentage": 0.18712335357307075, + "loser_percentage": 0.657845419356025, + "no_change_percentage": 0.7113805920262503, + "population": 1255582.067934901 + }, + { + "district": "MT-01", + "average_household_income_change": 983.512209057535, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6886199078768173, + "loser_percentage": 0.25605725846858585, + "no_change_percentage": 0.09915451748889526, + "population": 719811.1730435372 + }, + { + "district": "MT-02", + "average_household_income_change": -1226.0794958019412, + "relative_household_income_change": 0.5612276116099638, + "winner_percentage": 0.28209905361394183, + "loser_percentage": 0.5945383380085424, + "no_change_percentage": 0.7352463894684987, + "population": 397016.73677204945 + }, + { + "district": "NE-01", + "average_household_income_change": -1178.9227940818466, + "relative_household_income_change": 0.0, + "winner_percentage": 0.7717445355667062, + "loser_percentage": 0.9548158300345837, + "no_change_percentage": 0.36881783488012165, + "population": 1458335.460089935 + }, + { + "district": "NE-02", + "average_household_income_change": 778.2964863920827, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.4417767511041598, + "loser_percentage": 0.6507108550430083, + "no_change_percentage": 0.711661616417475, + "population": 967943.6800451052 + }, + { + "district": "NE-03", + "average_household_income_change": -922.672970623951, + "relative_household_income_change": 0.6319237715503292, + "winner_percentage": 0.09726516916592454, + "loser_percentage": 0.6428243489026462, + "no_change_percentage": 0.4190339797262024, + "population": 323762.0653781112 + }, + { + "district": "NV-01", + "average_household_income_change": -688.8600459298738, + "relative_household_income_change": 0.06928171539895811, + "winner_percentage": 0.5809572073479989, + "loser_percentage": 0.5315051068930021, + "no_change_percentage": 0.767522214563325, + "population": 910692.5784934496 + }, + { + "district": "NV-02", + "average_household_income_change": 1709.6034596338495, + "relative_household_income_change": 0.28167393496270166, + "winner_percentage": 0.23278075110677732, + "loser_percentage": 0.6063454319254328, + "no_change_percentage": 0.09937772231269837, + "population": 1597996.9889687065 + }, + { + "district": "NV-03", + "average_household_income_change": 1408.0974916503135, + "relative_household_income_change": 0.0, + "winner_percentage": 0.0941601815928258, + "loser_percentage": 0.14353136380653897, + "no_change_percentage": 0.5629582751261204, + "population": 477334.4048267159 + }, + { + "district": "NV-04", + "average_household_income_change": -427.9378679351966, + "relative_household_income_change": 0.0, + "winner_percentage": 0.775371200821322, + "loser_percentage": 0.8848608727622929, + "no_change_percentage": 0.2384576473390242, + "population": 520367.63519200013 + }, + { + "district": "NH-01", + "average_household_income_change": 1326.8209031397103, + "relative_household_income_change": 0.0, + "winner_percentage": 0.05550972747813998, + "loser_percentage": 0.34506329326687946, + "no_change_percentage": 0.4858060824382846, + "population": 1215752.0712104617 + }, + { + "district": "NH-02", + "average_household_income_change": -1941.5662836592323, + "relative_household_income_change": 0.0, + "winner_percentage": 0.8913316946883595, + "loser_percentage": 0.15248270653918305, + "no_change_percentage": 0.2127113800760161, + "population": 999683.2521774701 + }, + { + "district": "NJ-01", + "average_household_income_change": -1346.610051685778, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8705037018178549, + "loser_percentage": 0.9332653671497912, + "no_change_percentage": 0.0013087110055514106, + "population": 41657.1833312962 + }, + { + "district": "NJ-02", + "average_household_income_change": -1430.9975323618924, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3961612522306085, + "loser_percentage": 0.5497374172842523, + "no_change_percentage": 0.9570351353602428, + "population": 988525.2115806546 + }, + { + "district": "NJ-03", + "average_household_income_change": -480.9490596831703, + "relative_household_income_change": 0.8715069679828527, + "winner_percentage": 0.4162030039946538, + "loser_percentage": 0.8147648361198342, + "no_change_percentage": 0.2477053114997656, + "population": 200620.21363778634 + }, + { + "district": "NJ-04", + "average_household_income_change": -1064.0883488234874, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.25384228249797125, + "loser_percentage": 0.07328308823761476, + "no_change_percentage": 0.10878178812122041, + "population": 1600451.562733883 + }, + { + "district": "NJ-05", + "average_household_income_change": 7.583440590375634, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6524841274565424, + "loser_percentage": 0.5011181621406384, + "no_change_percentage": 0.4005971159936217, + "population": 642122.7083181444 + }, + { + "district": "NJ-06", + "average_household_income_change": 1847.4813653025826, + "relative_household_income_change": 0.2827700149160155, + "winner_percentage": 0.4871872762468712, + "loser_percentage": 0.09090848080435499, + "no_change_percentage": 0.8499029659176067, + "population": 1764753.0816979911 + }, + { + "district": "NJ-07", + "average_household_income_change": 297.92674740995295, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3579658557678955, + "loser_percentage": 0.020493003160175216, + "no_change_percentage": 0.9566814801456087, + "population": 935278.8794728584 + }, + { + "district": "NJ-08", + "average_household_income_change": -644.7962448650793, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7972547616227862, + "loser_percentage": 0.22063907980487918, + "no_change_percentage": 0.8316761144777678, + "population": 1229915.3582338342 + }, + { + "district": "NJ-09", + "average_household_income_change": 1045.5579441464936, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.702622771047097, + "loser_percentage": 0.20381647945908887, + "no_change_percentage": 0.32418582253424266, + "population": 1564811.1773359927 + }, + { + "district": "NJ-10", + "average_household_income_change": -1692.9557828654338, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.9267951257709088, + "loser_percentage": 0.8464769553409315, + "no_change_percentage": 0.5949924228024286, + "population": 803577.459125552 + }, + { + "district": "NJ-11", + "average_household_income_change": -1614.937124481708, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3196812411637635, + "loser_percentage": 0.2890420591557624, + "no_change_percentage": 0.9655833503800089, + "population": 659520.9005394165 + }, + { + "district": "NJ-12", + "average_household_income_change": -1313.8963892744994, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8108024249786424, + "loser_percentage": 0.2677947791608425, + "no_change_percentage": 0.6793089734463279, + "population": 1197655.9216551264 + }, + { + "district": "NM-01", + "average_household_income_change": 665.160281218299, + "relative_household_income_change": -0.0, + "winner_percentage": 0.10307122147386438, + "loser_percentage": 0.10536976699570999, + "no_change_percentage": 0.6019343481516051, + "population": 1805298.145269294 + }, + { + "district": "NM-02", + "average_household_income_change": 758.7531207589988, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6688154452139765, + "loser_percentage": 0.9238301237088424, + "no_change_percentage": 0.970947528110894, + "population": 1674333.7811092706 + }, + { + "district": "NM-03", + "average_household_income_change": -1410.5419522807897, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5035464357324184, + "loser_percentage": 0.09269884575570186, + "no_change_percentage": 0.3889352298588644, + "population": 1264979.3720552137 + }, + { + "district": "NY-01", + "average_household_income_change": -1131.9068991340923, + "relative_household_income_change": 0.5124881192237833, + "winner_percentage": 0.043046256625358104, + "loser_percentage": 0.28712796446459776, + "no_change_percentage": 0.9838704782107919, + "population": 205012.35009282408 + }, + { + "district": "NY-02", + "average_household_income_change": -477.0191933506838, + "relative_household_income_change": 0.0, + "winner_percentage": 0.4581353685754621, + "loser_percentage": 0.867650173463171, + "no_change_percentage": 0.9665102631560167, + "population": 1767864.3394364882 + }, + { + "district": "NY-03", + "average_household_income_change": -757.0547505228901, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5814616489172288, + "loser_percentage": 0.35448345534666637, + "no_change_percentage": 0.02075688966386391, + "population": 1430049.911309068 + }, + { + "district": "NY-04", + "average_household_income_change": 30.62315461557752, + "relative_household_income_change": 0.0, + "winner_percentage": 0.28234767077960055, + "loser_percentage": 0.5712067988140119, + "no_change_percentage": 0.17006966026273418, + "population": 1147280.7963970727 + }, + { + "district": "NY-05", + "average_household_income_change": 1598.90583811054, + "relative_household_income_change": 0.0, + "winner_percentage": 0.662399958732312, + "loser_percentage": 0.4758014996172041, + "no_change_percentage": 0.9788769458498555, + "population": 209471.40409972053 + }, + { + "district": "NY-06", + "average_household_income_change": 504.5097612222769, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6602851280753035, + "loser_percentage": 0.13842947192019328, + "no_change_percentage": 0.4769611571458495, + "population": 505524.22618424473 + }, + { + "district": "NY-07", + "average_household_income_change": 96.57497287162408, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.229946620086402, + "loser_percentage": 0.7237784986880081, + "no_change_percentage": 0.48589571188743963, + "population": 282855.9638871306 + }, + { + "district": "NY-08", + "average_household_income_change": -885.3169937163821, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.06829423577168159, + "loser_percentage": 0.045665461944919716, + "no_change_percentage": 0.09213515158018915, + "population": 1993094.250617465 + }, + { + "district": "NY-09", + "average_household_income_change": -9.819844727013916, + "relative_household_income_change": -0.0, + "winner_percentage": 0.49939512073940096, + "loser_percentage": 0.8090282530465259, + "no_change_percentage": 0.29321700485540136, + "population": 1681118.686924439 + }, + { + "district": "NY-10", + "average_household_income_change": -1643.0299274703914, + "relative_household_income_change": 0.0, + "winner_percentage": 0.8417835247167857, + "loser_percentage": 0.276875009337478, + "no_change_percentage": 0.8327242279468658, + "population": 164428.2753839366 + }, + { + "district": "NY-11", + "average_household_income_change": 208.95180757382238, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6332524715018043, + "loser_percentage": 0.274690539324145, + "no_change_percentage": 0.06459456905720373, + "population": 1724656.115367233 + }, + { + "district": "NY-12", + "average_household_income_change": 1722.1994291750575, + "relative_household_income_change": 0.0, + "winner_percentage": 0.028903587133481956, + "loser_percentage": 0.3340357928932579, + "no_change_percentage": 0.1664276573795902, + "population": 261655.8073576924 + }, + { + "district": "NY-13", + "average_household_income_change": -1745.0492620231976, + "relative_household_income_change": 0.0, + "winner_percentage": 0.7205626808437235, + "loser_percentage": 0.6470267452056409, + "no_change_percentage": 0.16648296179389988, + "population": 1796774.8541061652 + }, + { + "district": "NY-14", + "average_household_income_change": -531.5544893596945, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.2280700295259258, + "loser_percentage": 0.4236858118644774, + "no_change_percentage": 0.26285857414761926, + "population": 949264.7736125515 + }, + { + "district": "NY-15", + "average_household_income_change": 1035.1640699670766, + "relative_household_income_change": -0.0, + "winner_percentage": 0.5029805375678822, + "loser_percentage": 0.849430602105124, + "no_change_percentage": 0.4518045751817217, + "population": 746851.0845919283 + }, + { + "district": "NY-16", + "average_household_income_change": 260.053738656719, + "relative_household_income_change": -0.0, + "winner_percentage": 0.11890264690165409, + "loser_percentage": 0.09255369884592246, + "no_change_percentage": 0.6021350418455398, + "population": 1139978.6944879943 + }, + { + "district": "NY-17", + "average_household_income_change": 551.1459088188273, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.2568565800548146, + "loser_percentage": 0.0658448817049383, + "no_change_percentage": 0.5905054752811186, + "population": 1769856.515164564 + }, + { + "district": "NY-18", + "average_household_income_change": 203.09432013447682, + "relative_household_income_change": -0.0, + "winner_percentage": 0.597936552673326, + "loser_percentage": 0.7150144141312778, + "no_change_percentage": 0.724789937585622, + "population": 520257.4395910977 + }, + { + "district": "NY-19", + "average_household_income_change": -1716.2211584708255, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6519717033404463, + "loser_percentage": 0.7779712516376676, + "no_change_percentage": 0.17157435465193505, + "population": 1591882.803708056 + }, + { + "district": "NY-20", + "average_household_income_change": 1531.0381811405045, + "relative_household_income_change": -0.0, + "winner_percentage": 0.05659121270802303, + "loser_percentage": 0.11572867482718951, + "no_change_percentage": 0.41368525303321957, + "population": 798354.4591802023 + }, + { + "district": "NY-21", + "average_household_income_change": -1481.0177408200632, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.6685485548771268, + "loser_percentage": 0.02926629431828265, + "no_change_percentage": 0.3220783749376873, + "population": 665986.4590287958 + }, + { + "district": "NY-22", + "average_household_income_change": 1110.8941560254143, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.4214323670077975, + "loser_percentage": 0.10234225456208867, + "no_change_percentage": 0.376245986759273, + "population": 544219.2144474802 + }, + { + "district": "NY-23", + "average_household_income_change": 684.7430799549437, + "relative_household_income_change": -0.0, + "winner_percentage": 0.3068240717924594, + "loser_percentage": 0.975577878932322, + "no_change_percentage": 0.781071020452218, + "population": 1175599.50257008 + }, + { + "district": "NY-24", + "average_household_income_change": -1120.5230243440383, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.24738329428120054, + "loser_percentage": 0.5051294587237802, + "no_change_percentage": 0.8702153105443287, + "population": 1007687.6809980447 + }, + { + "district": "NY-25", + "average_household_income_change": 1495.6211572010943, + "relative_household_income_change": 0.0, + "winner_percentage": 0.3327362674239437, + "loser_percentage": 0.3980352900226982, + "no_change_percentage": 0.6395244769245754, + "population": 309126.55115195736 + }, + { + "district": "NY-26", + "average_household_income_change": 1005.7524739921187, + "relative_household_income_change": 0.09384776304331244, + "winner_percentage": 0.5132535713329025, + "loser_percentage": 0.11991430122939095, + "no_change_percentage": 0.7204974469291235, + "population": 1780659.23172649 + }, + { + "district": "NC-01", + "average_household_income_change": 1905.7524663701408, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.8819134926398041, + "loser_percentage": 0.44699135441524274, + "no_change_percentage": 0.4400285541797978, + "population": 1801522.6923972447 + }, + { + "district": "NC-02", + "average_household_income_change": 812.7845438528553, + "relative_household_income_change": 0.13890439070678073, + "winner_percentage": 0.06797734049370718, + "loser_percentage": 0.745111961730822, + "no_change_percentage": 0.3039520706041381, + "population": 1010127.7867981759 + }, + { + "district": "NC-03", + "average_household_income_change": -1126.1533846759257, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9059222479170311, + "loser_percentage": 0.6088042548949335, + "no_change_percentage": 0.8361479455723221, + "population": 1253346.8417832286 + }, + { + "district": "NC-04", + "average_household_income_change": 1588.7537548469916, + "relative_household_income_change": -0.0, + "winner_percentage": 0.12372180888852413, + "loser_percentage": 0.3926530286250651, + "no_change_percentage": 0.07132101791808032, + "population": 1999747.478495196 + }, + { + "district": "NC-05", + "average_household_income_change": 634.647556682796, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.32064494301872304, + "loser_percentage": 0.6033236611754655, + "no_change_percentage": 0.7298074029257416, + "population": 1431115.4234459244 + }, + { + "district": "NC-06", + "average_household_income_change": 347.2242650809525, + "relative_household_income_change": -0.0, + "winner_percentage": 0.36731627649607035, + "loser_percentage": 0.8032802872387302, + "no_change_percentage": 0.746072309880131, + "population": 624814.0610123319 + }, + { + "district": "NC-07", + "average_household_income_change": 444.4180985521879, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7289746258995076, + "loser_percentage": 0.3690668818213545, + "no_change_percentage": 0.37433748707080783, + "population": 1800904.0843756036 + }, + { + "district": "NC-08", + "average_household_income_change": 343.9543664215217, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.2889409643152038, + "loser_percentage": 0.29650633313595953, + "no_change_percentage": 0.11314476007451024, + "population": 582017.0773053442 + }, + { + "district": "NC-09", + "average_household_income_change": 847.1751648054621, + "relative_household_income_change": 0.06368776621470995, + "winner_percentage": 0.771368497288142, + "loser_percentage": 0.5813582055912243, + "no_change_percentage": 0.10983216055702494, + "population": 573445.3001250993 + }, + { + "district": "NC-10", + "average_household_income_change": -644.2881383254942, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.23855349169819606, + "loser_percentage": 0.14772417607751975, + "no_change_percentage": 0.5789166367792238, + "population": 628706.4061903722 + }, + { + "district": "NC-11", + "average_household_income_change": 1798.3776681929794, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6038503969417593, + "loser_percentage": 0.6954240140826077, + "no_change_percentage": 0.25102031418475945, + "population": 1036069.6351497878 + }, + { + "district": "NC-12", + "average_household_income_change": -1153.2334003615556, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.6494020962773108, + "loser_percentage": 0.68112893847386, + "no_change_percentage": 0.7945625910754524, + "population": 453780.4738603879 + }, + { + "district": "NC-13", + "average_household_income_change": 861.2013752677723, + "relative_household_income_change": 0.0, + "winner_percentage": 0.2529435759627967, + "loser_percentage": 0.6842805912026738, + "no_change_percentage": 0.691510662077478, + "population": 681472.2032578625 + }, + { + "district": "NC-14", + "average_household_income_change": -843.4969218054666, + "relative_household_income_change": -0.0, + "winner_percentage": 0.17844476062520975, + "loser_percentage": 0.02440019156351525, + "no_change_percentage": 0.17493552120416667, + "population": 325878.15639773593 + }, + { + "district": "ND-01", + "average_household_income_change": -538.4817956617094, + "relative_household_income_change": 0.0, + "winner_percentage": 0.11051184383413304, + "loser_percentage": 0.41292386930040537, + "no_change_percentage": 0.06585623221099801, + "population": 390452.8819427029 + }, + { + "district": "OH-01", + "average_household_income_change": 1429.5334028291213, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5278426955891395, + "loser_percentage": 0.6005590283137375, + "no_change_percentage": 0.15869088860396208, + "population": 82774.95630957745 + }, + { + "district": "OH-02", + "average_household_income_change": 247.34152192959513, + "relative_household_income_change": 0.0, + "winner_percentage": 0.7216089357104507, + "loser_percentage": 0.8680780650241383, + "no_change_percentage": 0.00885484187005492, + "population": 831747.4468137303 + }, + { + "district": "OH-03", + "average_household_income_change": 181.29081106309462, + "relative_household_income_change": -0.0, + "winner_percentage": 0.374288814448134, + "loser_percentage": 0.8938726257407104, + "no_change_percentage": 0.5813620810563472, + "population": 1804950.9344293228 + }, + { + "district": "OH-04", + "average_household_income_change": 800.7647640367987, + "relative_household_income_change": 0.8207485460353191, + "winner_percentage": 0.11277780936311466, + "loser_percentage": 0.25265940767418205, + "no_change_percentage": 0.9111574621350451, + "population": 1794784.9527226053 + }, + { + "district": "OH-05", + "average_household_income_change": -1562.6244364927354, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9966657867352428, + "loser_percentage": 0.10886754706831803, + "no_change_percentage": 0.8545825291242566, + "population": 233265.7912630831 + }, + { + "district": "OH-06", + "average_household_income_change": -883.7521616253334, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5164168845139231, + "loser_percentage": 0.027945412433999173, + "no_change_percentage": 0.21120343602251246, + "population": 177783.19323167205 + }, + { + "district": "OH-07", + "average_household_income_change": -188.90997633302487, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.9380606200193555, + "loser_percentage": 0.34740716217814394, + "no_change_percentage": 0.8893278594350348, + "population": 964015.0225381623 + }, + { + "district": "OH-08", + "average_household_income_change": 222.99145786351636, + "relative_household_income_change": 0.2599862225866242, + "winner_percentage": 0.7865415348656284, + "loser_percentage": 0.5699768907923377, + "no_change_percentage": 0.7590644900019196, + "population": 898104.1320936508 + }, + { + "district": "OH-09", + "average_household_income_change": 933.2464358174275, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5186146433595739, + "loser_percentage": 0.03456639265822781, + "no_change_percentage": 0.27847571623212297, + "population": 1678208.3304820228 + }, + { + "district": "OH-10", + "average_household_income_change": -1327.57201116124, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2890878055085202, + "loser_percentage": 0.8531745179280665, + "no_change_percentage": 0.7718057506643415, + "population": 1838588.7533272905 + }, + { + "district": "OH-11", + "average_household_income_change": 1750.127418250961, + "relative_household_income_change": 0.0, + "winner_percentage": 0.4939449029501517, + "loser_percentage": 0.19558528920942286, + "no_change_percentage": 0.14683928808809676, + "population": 117625.8044139602 + }, + { + "district": "OH-12", + "average_household_income_change": 1111.884158618861, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6528616464533499, + "loser_percentage": 0.021400312488671913, + "no_change_percentage": 0.030273923372256095, + "population": 234545.10408303817 + }, + { + "district": "OH-13", + "average_household_income_change": -687.0037649600005, + "relative_household_income_change": 0.0, + "winner_percentage": 0.17953726005725024, + "loser_percentage": 0.9367507607303269, + "no_change_percentage": 0.19810480784373719, + "population": 1608747.5418619215 + }, + { + "district": "OH-14", + "average_household_income_change": 1002.2901963771469, + "relative_household_income_change": 0.949115972430235, + "winner_percentage": 0.6452992282399889, + "loser_percentage": 0.6732223970621404, + "no_change_percentage": 0.8211002682278746, + "population": 1776435.5005847374 + }, + { + "district": "OH-15", + "average_household_income_change": -713.3260828235599, + "relative_household_income_change": 0.8153911026362762, + "winner_percentage": 0.9751500980329527, + "loser_percentage": 0.1837252033481077, + "no_change_percentage": 0.6880747422849436, + "population": 1904353.6250990205 + }, + { + "district": "OK-01", + "average_household_income_change": 1460.3826473514553, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7070840854137713, + "loser_percentage": 0.30215987451830306, + "no_change_percentage": 0.5015683823012259, + "population": 1139495.53806115 + }, + { + "district": "OK-02", + "average_household_income_change": 1083.7770389931225, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2890350001539308, + "loser_percentage": 0.9653428558779258, + "no_change_percentage": 0.19620231019756684, + "population": 162863.33019325696 + }, + { + "district": "OK-03", + "average_household_income_change": -937.8420013990443, + "relative_household_income_change": 0.4727799567491602, + "winner_percentage": 0.3201734466820204, + "loser_percentage": 0.022463908344121597, + "no_change_percentage": 0.35784388173343773, + "population": 1275767.40827854 + }, + { + "district": "OK-04", + "average_household_income_change": 1080.8028799910123, + "relative_household_income_change": 0.9267783457836459, + "winner_percentage": 0.9681426826536599, + "loser_percentage": 0.24653060984129416, + "no_change_percentage": 0.9900239027127675, + "population": 690757.1648270963 + }, + { + "district": "OK-05", + "average_household_income_change": -186.4124367820863, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.38612199074311315, + "loser_percentage": 0.39905328159098163, + "no_change_percentage": 0.5755953780919827, + "population": 314083.9250144502 + }, + { + "district": "OR-01", + "average_household_income_change": 1215.0282215772452, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6001265471383288, + "loser_percentage": 0.9671144958686907, + "no_change_percentage": 0.838732929258236, + "population": 313837.5584065651 + }, + { + "district": "OR-02", + "average_household_income_change": 704.6383715214604, + "relative_household_income_change": 0.7029758642693501, + "winner_percentage": 0.8293483474290964, + "loser_percentage": 0.5755792575435204, + "no_change_percentage": 0.9333065230896005, + "population": 1464348.4347723061 + }, + { + "district": "OR-03", + "average_household_income_change": -873.0524531996738, + "relative_household_income_change": 0.0, + "winner_percentage": 0.2377304067129462, + "loser_percentage": 0.16987010804218217, + "no_change_percentage": 0.19668375289592133, + "population": 1394621.2240415476 + }, + { + "district": "OR-04", + "average_household_income_change": 1899.8809389932742, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2679186601454181, + "loser_percentage": 0.9327699126738945, + "no_change_percentage": 0.3492058947704546, + "population": 669531.8836256716 + }, + { + "district": "OR-05", + "average_household_income_change": -693.9948241065301, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.8890441269074849, + "loser_percentage": 0.004487024580698384, + "no_change_percentage": 0.42569268304669017, + "population": 1654538.1237349864 + }, + { + "district": "OR-06", + "average_household_income_change": 1963.1431541606328, + "relative_household_income_change": 0.0, + "winner_percentage": 0.8270559556960193, + "loser_percentage": 0.2337614248102322, + "no_change_percentage": 0.6327919707602321, + "population": 602386.5793205993 + }, + { + "district": "PA-01", + "average_household_income_change": -1724.7276769199145, + "relative_household_income_change": 0.0791408204484465, + "winner_percentage": 0.3384174936357105, + "loser_percentage": 0.28023895938453636, + "no_change_percentage": 0.15202515307938014, + "population": 1651262.3038585437 + }, + { + "district": "PA-02", + "average_household_income_change": 1089.8441601017366, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7689888611451111, + "loser_percentage": 0.6930673921450239, + "no_change_percentage": 0.2060269791840018, + "population": 897542.4105421195 + }, + { + "district": "PA-03", + "average_household_income_change": -1969.1553365678662, + "relative_household_income_change": -0.0, + "winner_percentage": 0.05619416354044959, + "loser_percentage": 0.4384500983984615, + "no_change_percentage": 0.3180855632642814, + "population": 281678.32508080103 + }, + { + "district": "PA-04", + "average_household_income_change": 285.9503748558221, + "relative_household_income_change": 0.0, + "winner_percentage": 0.428737064786506, + "loser_percentage": 0.6940226808823239, + "no_change_percentage": 0.8569663707062056, + "population": 1721941.9270078402 + }, + { + "district": "PA-05", + "average_household_income_change": 997.6363395334391, + "relative_household_income_change": 0.9818112362598781, + "winner_percentage": 0.4502167723979821, + "loser_percentage": 0.12921709727170727, + "no_change_percentage": 0.12082370227430617, + "population": 295940.35761302616 + }, + { + "district": "PA-06", + "average_household_income_change": 665.1380166195513, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.07783017425906258, + "loser_percentage": 0.9647536084038852, + "no_change_percentage": 0.4847236296018368, + "population": 1626059.0412948849 + }, + { + "district": "PA-07", + "average_household_income_change": -471.4252506002263, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5757088097507093, + "loser_percentage": 0.603956925904559, + "no_change_percentage": 0.6430133895206961, + "population": 1973132.5320566306 + }, + { + "district": "PA-08", + "average_household_income_change": -925.3953112617737, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9231177478320901, + "loser_percentage": 0.8934945233526166, + "no_change_percentage": 0.028048398007435416, + "population": 1427743.709207689 + }, + { + "district": "PA-09", + "average_household_income_change": 1941.9594629095197, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5663048289142881, + "loser_percentage": 0.9751785263409453, + "no_change_percentage": 0.5087186098095644, + "population": 1751731.1897622026 + }, + { + "district": "PA-10", + "average_household_income_change": -168.88982054250468, + "relative_household_income_change": 0.0557679908403409, + "winner_percentage": 0.7502997397542419, + "loser_percentage": 0.840708859748251, + "no_change_percentage": 0.4231543787214357, + "population": 330194.5655087655 + }, + { + "district": "PA-11", + "average_household_income_change": -142.78281027491778, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5385685110982603, + "loser_percentage": 0.9064943105743645, + "no_change_percentage": 0.40045233370041333, + "population": 1313346.120477421 + }, + { + "district": "PA-12", + "average_household_income_change": 729.1416517787517, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.2808591493475159, + "loser_percentage": 0.5302376654329679, + "no_change_percentage": 0.798006877012943, + "population": 1570502.0636920966 + }, + { + "district": "PA-13", + "average_household_income_change": -1010.1366291548954, + "relative_household_income_change": 0.9495430575625469, + "winner_percentage": 0.7609948129598099, + "loser_percentage": 0.05588084422096318, + "no_change_percentage": 0.2097914206301834, + "population": 212650.61445635825 + }, + { + "district": "PA-14", + "average_household_income_change": 193.65812691362328, + "relative_household_income_change": -0.0, + "winner_percentage": 0.3910458766204923, + "loser_percentage": 0.10675034109650516, + "no_change_percentage": 0.9594313286131515, + "population": 1751171.2271268014 + }, + { + "district": "PA-15", + "average_household_income_change": 1046.0629749759046, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6325803240880272, + "loser_percentage": 0.493105175529035, + "no_change_percentage": 0.4439200089858375, + "population": 63941.620072624995 + }, + { + "district": "PA-16", + "average_household_income_change": 1008.7316836972371, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6399996688075169, + "loser_percentage": 0.29180332739195713, + "no_change_percentage": 0.05235921925952458, + "population": 576947.4273465474 + }, + { + "district": "PA-17", + "average_household_income_change": -1717.8033293070962, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.1607192942837673, + "loser_percentage": 0.6626523627031539, + "no_change_percentage": 0.7392257404199818, + "population": 585286.8629265588 + }, + { + "district": "RI-01", + "average_household_income_change": -1550.5857205303325, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.6313800843931212, + "loser_percentage": 0.08215021086848406, + "no_change_percentage": 0.6494960270009935, + "population": 150834.82240956987 + }, + { + "district": "RI-02", + "average_household_income_change": -1465.6676100434124, + "relative_household_income_change": 0.0, + "winner_percentage": 0.7130625752476205, + "loser_percentage": 0.17460034652050782, + "no_change_percentage": 0.31426725005408773, + "population": 656190.5805730725 + }, + { + "district": "SC-01", + "average_household_income_change": 1937.2605734310505, + "relative_household_income_change": 0.8527070615837832, + "winner_percentage": 0.6689396741212047, + "loser_percentage": 0.8519964791926429, + "no_change_percentage": 0.8147617722049865, + "population": 460259.70526279614 + }, + { + "district": "SC-02", + "average_household_income_change": -1649.4072107732266, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.981992756769073, + "loser_percentage": 0.7032420090911612, + "no_change_percentage": 0.800160582343668, + "population": 296933.1304116682 + }, + { + "district": "SC-03", + "average_household_income_change": 1735.5185940756512, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6806667209436814, + "loser_percentage": 0.5663093565909887, + "no_change_percentage": 0.3417763897456615, + "population": 1623962.9294941647 + }, + { + "district": "SC-04", + "average_household_income_change": 897.709921417864, + "relative_household_income_change": 0.7014147411138223, + "winner_percentage": 0.2848775149342192, + "loser_percentage": 0.3324586260623994, + "no_change_percentage": 0.8577746284838489, + "population": 1343769.0691192998 + }, + { + "district": "SC-05", + "average_household_income_change": 931.9711004520146, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9517118339588264, + "loser_percentage": 0.21759914654466395, + "no_change_percentage": 0.5180028856504914, + "population": 1009850.8155595793 + }, + { + "district": "SC-06", + "average_household_income_change": 1795.8222332940825, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.852897153549233, + "loser_percentage": 0.8048781565393026, + "no_change_percentage": 0.16201608908649556, + "population": 49066.04191148456 + }, + { + "district": "SC-07", + "average_household_income_change": 1857.6974684671775, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6818888391172473, + "loser_percentage": 0.10539479653499773, + "no_change_percentage": 0.10947920866386618, + "population": 1474599.5751547609 + }, + { + "district": "SD-01", + "average_household_income_change": -185.12946706296242, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9583114599271013, + "loser_percentage": 0.8566897788956356, + "no_change_percentage": 0.49133982229752937, + "population": 1025155.0366304349 + }, + { + "district": "TN-01", + "average_household_income_change": 1990.8502151773625, + "relative_household_income_change": 0.08071195025176936, + "winner_percentage": 0.1424522722051551, + "loser_percentage": 0.2501611832548506, + "no_change_percentage": 0.7393905997045908, + "population": 509389.427303286 + }, + { + "district": "TN-02", + "average_household_income_change": 971.2647989363505, + "relative_household_income_change": 0.0, + "winner_percentage": 0.8322193743039388, + "loser_percentage": 0.7729427687676738, + "no_change_percentage": 0.6847091596468332, + "population": 44818.485711245215 + }, + { + "district": "TN-03", + "average_household_income_change": 211.71389701787734, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.4102747777269825, + "loser_percentage": 0.1076519599220832, + "no_change_percentage": 0.4359652614253863, + "population": 612016.9535068158 + }, + { + "district": "TN-04", + "average_household_income_change": -1166.3652707529218, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6121302979479828, + "loser_percentage": 0.4740172036626341, + "no_change_percentage": 0.31505716327074385, + "population": 1814735.1803401406 + }, + { + "district": "TN-05", + "average_household_income_change": -1710.819262182215, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.38390327544462677, + "loser_percentage": 0.3353685612318471, + "no_change_percentage": 0.004715856283499242, + "population": 190008.9265465967 + }, + { + "district": "TN-06", + "average_household_income_change": 197.01400925287825, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7802816388354746, + "loser_percentage": 0.7305906386275102, + "no_change_percentage": 0.29038385863341176, + "population": 1900723.3659439501 + }, + { + "district": "TN-07", + "average_household_income_change": 211.41923929555918, + "relative_household_income_change": 0.0015568199371647218, + "winner_percentage": 0.20229323028117163, + "loser_percentage": 0.022150695649819307, + "no_change_percentage": 0.8006018451087608, + "population": 184644.49612084823 + }, + { + "district": "TN-08", + "average_household_income_change": 1328.484630047538, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.818603043336867, + "loser_percentage": 0.9312811484394016, + "no_change_percentage": 0.4553042462854815, + "population": 236288.20602635806 + }, + { + "district": "TN-09", + "average_household_income_change": 855.8315869415694, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5605413938252272, + "loser_percentage": 0.47705405595659556, + "no_change_percentage": 0.630088073594293, + "population": 1413809.7312156651 + }, + { + "district": "TX-01", + "average_household_income_change": 1771.1915705224733, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8751630156858404, + "loser_percentage": 0.9114745108775637, + "no_change_percentage": 0.3566363032064842, + "population": 1142428.890069966 + }, + { + "district": "TX-02", + "average_household_income_change": -1920.1601722975875, + "relative_household_income_change": 0.12208181487092995, + "winner_percentage": 0.4552730089959446, + "loser_percentage": 0.13482867731225923, + "no_change_percentage": 0.27201447248401667, + "population": 979537.4973681483 + }, + { + "district": "TX-03", + "average_household_income_change": 1016.9133388870835, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9864993640141612, + "loser_percentage": 0.32025950019262006, + "no_change_percentage": 0.3573404493437041, + "population": 137241.399852384 + }, + { + "district": "TX-04", + "average_household_income_change": 1892.9127819556993, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8989800061359351, + "loser_percentage": 0.6689276605507882, + "no_change_percentage": 0.8953958061825441, + "population": 829341.8250034208 + }, + { + "district": "TX-05", + "average_household_income_change": 1897.0403281518222, + "relative_household_income_change": 0.7958420854245689, + "winner_percentage": 0.25572293996702045, + "loser_percentage": 0.41508734432727257, + "no_change_percentage": 0.32680578798631954, + "population": 1246583.3372938028 + }, + { + "district": "TX-06", + "average_household_income_change": -1006.7452094515037, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.31716495088768626, + "loser_percentage": 0.41932200244509976, + "no_change_percentage": 0.5052989869058244, + "population": 114900.41963940545 + }, + { + "district": "TX-07", + "average_household_income_change": -986.750860859265, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.057531609216056045, + "loser_percentage": 0.11214276134254297, + "no_change_percentage": 0.8670485270497249, + "population": 1189524.561694502 + }, + { + "district": "TX-08", + "average_household_income_change": 468.61581361870196, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.15544793198009388, + "loser_percentage": 0.23765541453250072, + "no_change_percentage": 0.6203152864878174, + "population": 1553341.3028538858 + }, + { + "district": "TX-09", + "average_household_income_change": 592.3138853555479, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.09526139620272689, + "loser_percentage": 0.6789476722459857, + "no_change_percentage": 0.8930914340790302, + "population": 650602.021581664 + }, + { + "district": "TX-10", + "average_household_income_change": -771.8822058271178, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7667801354023313, + "loser_percentage": 0.4397946703112, + "no_change_percentage": 0.6944012866100824, + "population": 1514675.1003138293 + }, + { + "district": "TX-11", + "average_household_income_change": 445.10938641772714, + "relative_household_income_change": 0.7661649830512469, + "winner_percentage": 0.4350632876947641, + "loser_percentage": 0.9852093398616844, + "no_change_percentage": 0.02306253066745889, + "population": 536280.1697764277 + }, + { + "district": "TX-12", + "average_household_income_change": -1723.4154822526245, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.03244025476786705, + "loser_percentage": 0.9614424944997229, + "no_change_percentage": 0.4952649603765471, + "population": 1906516.5371909686 + }, + { + "district": "TX-13", + "average_household_income_change": 1812.3135179699345, + "relative_household_income_change": 0.9502675017988578, + "winner_percentage": 0.21105628812509547, + "loser_percentage": 0.31887128979074797, + "no_change_percentage": 0.4497497380723805, + "population": 562688.9379618007 + }, + { + "district": "TX-14", + "average_household_income_change": -862.4498626305681, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5468959965169081, + "loser_percentage": 0.25935447600951667, + "no_change_percentage": 0.7624524603063794, + "population": 1249745.2596928063 + }, + { + "district": "TX-15", + "average_household_income_change": 1134.5925667142494, + "relative_household_income_change": -0.0, + "winner_percentage": 0.18858944824948876, + "loser_percentage": 0.6909012927595274, + "no_change_percentage": 0.79550145373388, + "population": 1284249.0165737546 + }, + { + "district": "TX-16", + "average_household_income_change": -94.51012579706753, + "relative_household_income_change": 0.0, + "winner_percentage": 0.0481356954979375, + "loser_percentage": 0.029640494303868503, + "no_change_percentage": 0.08919240436367959, + "population": 154477.28857511468 + }, + { + "district": "TX-17", + "average_household_income_change": 123.32877219063931, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8167313479183358, + "loser_percentage": 0.4250737287064854, + "no_change_percentage": 0.6504954644323904, + "population": 1123673.307339278 + }, + { + "district": "TX-18", + "average_household_income_change": 1474.4750546742705, + "relative_household_income_change": -0.0, + "winner_percentage": 0.3096230476943209, + "loser_percentage": 0.6586386549698358, + "no_change_percentage": 0.10163494198560052, + "population": 36032.47793310054 + }, + { + "district": "TX-19", + "average_household_income_change": -36.34491580021493, + "relative_household_income_change": 0.0, + "winner_percentage": 0.17522246712590916, + "loser_percentage": 0.7155230138235702, + "no_change_percentage": 0.9265095903708955, + "population": 1892844.0312474421 + }, + { + "district": "TX-20", + "average_household_income_change": 482.6199015754944, + "relative_household_income_change": 0.0, + "winner_percentage": 0.04341115956437347, + "loser_percentage": 0.5589910741196304, + "no_change_percentage": 0.4497429279862738, + "population": 989503.7761538144 + }, + { + "district": "TX-21", + "average_household_income_change": -612.6131101039554, + "relative_household_income_change": 0.0, + "winner_percentage": 0.4466640199476205, + "loser_percentage": 0.9878969837278956, + "no_change_percentage": 0.8386056028138273, + "population": 222734.239231813 + }, + { + "district": "TX-22", + "average_household_income_change": 148.9376439441039, + "relative_household_income_change": 0.0, + "winner_percentage": 0.20146267050549793, + "loser_percentage": 0.8518191979985624, + "no_change_percentage": 0.38611458542964494, + "population": 1819989.6572932268 + }, + { + "district": "TX-23", + "average_household_income_change": -473.62444502771825, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2441475454205131, + "loser_percentage": 0.9219497484248206, + "no_change_percentage": 0.5222034568292102, + "population": 10823.250447892933 + }, + { + "district": "TX-24", + "average_household_income_change": -1672.3682809212992, + "relative_household_income_change": 0.07985271423887697, + "winner_percentage": 0.5084063876062761, + "loser_percentage": 0.7220018931521209, + "no_change_percentage": 0.2215680200641278, + "population": 1595642.3387817296 + }, + { + "district": "TX-25", + "average_household_income_change": 1077.5688699725806, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.24499324827021263, + "loser_percentage": 0.01432222664134064, + "no_change_percentage": 0.8939382050641294, + "population": 1787983.1005429467 + }, + { + "district": "TX-26", + "average_household_income_change": -1438.4307054370513, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6198227349896034, + "loser_percentage": 0.11800847290362171, + "no_change_percentage": 0.009304095159581083, + "population": 876845.671226172 + }, + { + "district": "TX-27", + "average_household_income_change": 1340.9013998003868, + "relative_household_income_change": 0.3019592228300463, + "winner_percentage": 0.28964951902839375, + "loser_percentage": 0.7622993632880837, + "no_change_percentage": 0.614048893466411, + "population": 123347.98733697983 + }, + { + "district": "TX-28", + "average_household_income_change": 488.0047599682156, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.9402775258918264, + "loser_percentage": 0.9393313495884833, + "no_change_percentage": 0.37357540068239203, + "population": 438695.46877367457 + }, + { + "district": "TX-29", + "average_household_income_change": 1036.8306845711284, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.025297254401102753, + "loser_percentage": 0.7802251539465428, + "no_change_percentage": 0.6236464207364651, + "population": 1735638.9703092447 + }, + { + "district": "TX-30", + "average_household_income_change": -1816.620642287472, + "relative_household_income_change": 0.0, + "winner_percentage": 0.2371013107937745, + "loser_percentage": 0.611306274602615, + "no_change_percentage": 0.8355836741413932, + "population": 910562.3826486651 + }, + { + "district": "TX-31", + "average_household_income_change": 681.3583160127758, + "relative_household_income_change": 0.0, + "winner_percentage": 0.008027992944522722, + "loser_percentage": 0.5828667622610165, + "no_change_percentage": 0.024280868889418383, + "population": 616179.7522015433 + }, + { + "district": "TX-32", + "average_household_income_change": -785.7929442762619, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6420402033057935, + "loser_percentage": 0.40296422224895634, + "no_change_percentage": 0.17046871016524834, + "population": 762591.0685096306 + }, + { + "district": "TX-33", + "average_household_income_change": 336.01806354446273, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9774601220731014, + "loser_percentage": 0.5795747826478493, + "no_change_percentage": 0.06407656228653436, + "population": 1669168.5241341875 + }, + { + "district": "TX-34", + "average_household_income_change": -1683.0786772779436, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.8589802594733764, + "loser_percentage": 0.5004712894867026, + "no_change_percentage": 0.8681841973260737, + "population": 695506.4088603129 + }, + { + "district": "TX-35", + "average_household_income_change": -645.2997247970522, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.037310115037405334, + "loser_percentage": 0.05650574967910238, + "no_change_percentage": 0.6290940416189846, + "population": 1123262.8945596083 + }, + { + "district": "TX-36", + "average_household_income_change": -1181.8042866717713, + "relative_household_income_change": 0.1539846603505851, + "winner_percentage": 0.8433985352622048, + "loser_percentage": 0.3449587698057731, + "no_change_percentage": 0.6164753452177081, + "population": 584047.538738149 + }, + { + "district": "TX-37", + "average_household_income_change": -773.9294231231523, + "relative_household_income_change": 0.0, + "winner_percentage": 0.43736613788104073, + "loser_percentage": 0.3930416960662526, + "no_change_percentage": 0.49201046742835874, + "population": 379840.7069587959 + }, + { + "district": "TX-38", + "average_household_income_change": 1856.1006064111475, + "relative_household_income_change": -0.0, + "winner_percentage": 0.1472091366732724, + "loser_percentage": 0.5111329976782713, + "no_change_percentage": 0.802506694325617, + "population": 1058823.7384297766 + }, + { + "district": "UT-01", + "average_household_income_change": 538.9230690131752, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8078746504361951, + "loser_percentage": 0.9086907148230051, + "no_change_percentage": 0.41718805953400595, + "population": 744308.05743929 + }, + { + "district": "UT-02", + "average_household_income_change": 625.4079449694609, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.9719555970079679, + "loser_percentage": 0.7046548863901538, + "no_change_percentage": 0.4750052548308249, + "population": 1194783.1107481858 + }, + { + "district": "UT-03", + "average_household_income_change": -1995.7045903511234, + "relative_household_income_change": -0.0, + "winner_percentage": 0.05076764633301312, + "loser_percentage": 0.9114782823193239, + "no_change_percentage": 0.4413152091515967, + "population": 362227.6352435327 + }, + { + "district": "UT-04", + "average_household_income_change": -677.9393110516531, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.37380705077204834, + "loser_percentage": 0.15795234703252115, + "no_change_percentage": 0.2040000223666747, + "population": 1494796.9803889361 + }, + { + "district": "VT-01", + "average_household_income_change": 353.4738828261714, + "relative_household_income_change": 0.0, + "winner_percentage": 0.8532040557443087, + "loser_percentage": 0.2235960450042347, + "no_change_percentage": 0.20134550743715096, + "population": 953075.3632854001 + }, + { + "district": "VA-01", + "average_household_income_change": 778.0368366974453, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.36308011279451313, + "loser_percentage": 0.15114920586672909, + "no_change_percentage": 0.8882407670893311, + "population": 1490063.1040169136 + }, + { + "district": "VA-02", + "average_household_income_change": -1010.0022195655356, + "relative_household_income_change": -0.0, + "winner_percentage": 0.399603834907748, + "loser_percentage": 0.9509916921568538, + "no_change_percentage": 0.9225239801101054, + "population": 684844.1720984051 + }, + { + "district": "VA-03", + "average_household_income_change": 1160.5277807052973, + "relative_household_income_change": 0.1768590513948327, + "winner_percentage": 0.9925433691281884, + "loser_percentage": 0.9119338074690182, + "no_change_percentage": 0.11498448813031215, + "population": 671222.2659518386 + }, + { + "district": "VA-04", + "average_household_income_change": 1075.5192645445359, + "relative_household_income_change": 0.0, + "winner_percentage": 0.3117099809717838, + "loser_percentage": 0.18532163428785997, + "no_change_percentage": 0.8815658664157163, + "population": 651704.0237652881 + }, + { + "district": "VA-05", + "average_household_income_change": -1592.2137507489542, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.05487263637982431, + "loser_percentage": 0.7545716977655509, + "no_change_percentage": 0.42546699188341486, + "population": 1960083.0280343024 + }, + { + "district": "VA-06", + "average_household_income_change": 173.80291494289946, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.32986302289622504, + "loser_percentage": 0.9004772463524898, + "no_change_percentage": 0.27213789635182417, + "population": 1104186.0216722307 + }, + { + "district": "VA-07", + "average_household_income_change": -1168.1843187495872, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.9468439755493232, + "loser_percentage": 0.6741460863894962, + "no_change_percentage": 0.22514942250463243, + "population": 1144019.3339419388 + }, + { + "district": "VA-08", + "average_household_income_change": 1871.03639237891, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2178838122898782, + "loser_percentage": 0.5505946063075128, + "no_change_percentage": 0.061186857186479204, + "population": 333578.719343903 + }, + { + "district": "VA-09", + "average_household_income_change": -1811.655724093852, + "relative_household_income_change": -0.0, + "winner_percentage": 0.13689686569124193, + "loser_percentage": 0.6687216826093308, + "no_change_percentage": 0.497092817382386, + "population": 494593.77289942297 + }, + { + "district": "VA-10", + "average_household_income_change": -815.4371158922849, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7856146432574787, + "loser_percentage": 0.940402140652285, + "no_change_percentage": 0.2895285985773879, + "population": 1214469.82023329 + }, + { + "district": "VA-11", + "average_household_income_change": -360.1121235607505, + "relative_household_income_change": -0.0, + "winner_percentage": 0.3753365275814715, + "loser_percentage": 0.6813036121555542, + "no_change_percentage": 0.8724926283246953, + "population": 707703.9404915355 + }, + { + "district": "WA-01", + "average_household_income_change": 105.63506368520504, + "relative_household_income_change": -0.0, + "winner_percentage": 0.43902152935839744, + "loser_percentage": 0.11987463568444745, + "no_change_percentage": 0.9262362054297277, + "population": 1268970.990725781 + }, + { + "district": "WA-02", + "average_household_income_change": 1691.1267713793006, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8109962062353352, + "loser_percentage": 0.5735816744632423, + "no_change_percentage": 0.36983592279142374, + "population": 1370317.1348295836 + }, + { + "district": "WA-03", + "average_household_income_change": 40.5972770329508, + "relative_household_income_change": 0.9835896952422006, + "winner_percentage": 0.4652226252562398, + "loser_percentage": 0.7078615262701772, + "no_change_percentage": 0.8071584891291584, + "population": 872059.7943409345 + }, + { + "district": "WA-04", + "average_household_income_change": 1732.5251561029313, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7946573412175163, + "loser_percentage": 0.9188465382642514, + "no_change_percentage": 0.5385030078329925, + "population": 1632884.0302181193 + }, + { + "district": "WA-05", + "average_household_income_change": 1202.1063187299212, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.757030017005171, + "loser_percentage": 0.3806079639722343, + "no_change_percentage": 0.42515199640370005, + "population": 931161.6787912422 + }, + { + "district": "WA-06", + "average_household_income_change": -41.683066187413246, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7419630561106269, + "loser_percentage": 0.7982884089457194, + "no_change_percentage": 0.5766984839678304, + "population": 1925306.5372831763 + }, + { + "district": "WA-07", + "average_household_income_change": -1038.8288175164387, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7487836469612043, + "loser_percentage": 0.04627926690743911, + "no_change_percentage": 0.7637763617204205, + "population": 1394007.9334284214 + }, + { + "district": "WA-08", + "average_household_income_change": -113.39026359794602, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.14656233016666997, + "loser_percentage": 0.14227889496061186, + "no_change_percentage": 0.8942400203221307, + "population": 1159468.3679830425 + }, + { + "district": "WA-09", + "average_household_income_change": -1188.6320903149917, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6848291117385173, + "loser_percentage": 0.7192944917615914, + "no_change_percentage": 0.47453869785998914, + "population": 1237632.7477468404 + }, + { + "district": "WA-10", + "average_household_income_change": -1918.8638263145253, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9811299633185824, + "loser_percentage": 0.07117563047989073, + "no_change_percentage": 0.40085829518483473, + "population": 1658622.2639309997 + }, + { + "district": "WV-01", + "average_household_income_change": -1719.9286068807128, + "relative_household_income_change": -0.0, + "winner_percentage": 0.9148158153481513, + "loser_percentage": 0.08663114488601364, + "no_change_percentage": 0.4853402444158086, + "population": 1344776.2874544123 + }, + { + "district": "WV-02", + "average_household_income_change": -1825.9565032373657, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9883328689997977, + "loser_percentage": 0.1932626210579047, + "no_change_percentage": 0.17022618841907056, + "population": 1867594.7605861642 + }, + { + "district": "WI-01", + "average_household_income_change": 1290.4459829291638, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.18361453960505825, + "loser_percentage": 0.5292730478053601, + "no_change_percentage": 0.25849761902750124, + "population": 1729840.943088221 + }, + { + "district": "WI-02", + "average_household_income_change": 1181.9427409174762, + "relative_household_income_change": 0.6753359662801989, + "winner_percentage": 0.9024734844569519, + "loser_percentage": 0.7123801306820899, + "no_change_percentage": 0.497654118406206, + "population": 212370.7586732047 + }, + { + "district": "WI-03", + "average_household_income_change": -1899.8950693658596, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.6429595737137347, + "loser_percentage": 0.7202951055596833, + "no_change_percentage": 0.15965232924903816, + "population": 1248918.761505698 + }, + { + "district": "WI-04", + "average_household_income_change": -1923.8872113506757, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7134800021365812, + "loser_percentage": 0.16077300392286054, + "no_change_percentage": 0.4204664571222778, + "population": 529063.6749248685 + }, + { + "district": "WI-05", + "average_household_income_change": -1150.9174218021424, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.2447208698368899, + "loser_percentage": 0.6477205033828267, + "no_change_percentage": 0.2917078822429261, + "population": 591469.1625126833 + }, + { + "district": "WI-06", + "average_household_income_change": 1122.465409431541, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.030952864425620397, + "loser_percentage": 0.13790808032567214, + "no_change_percentage": 0.1335488088157848, + "population": 375057.91329466144 + }, + { + "district": "WI-07", + "average_household_income_change": 514.946210223933, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.36936029678754523, + "loser_percentage": 0.3861623591027703, + "no_change_percentage": 0.6236829834736486, + "population": 441020.0640803472 + }, + { + "district": "WI-08", + "average_household_income_change": -1220.801385442289, + "relative_household_income_change": 0.13138239333980972, + "winner_percentage": 0.9148413673253681, + "loser_percentage": 0.2235683708974472, + "no_change_percentage": 0.1651680949063037, + "population": 320289.09370709525 + }, + { + "district": "WY-01", + "average_household_income_change": 541.4347808810725, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.33422766415601324, + "loser_percentage": 0.7974131376543324, + "no_change_percentage": 0.10343429179638364, + "population": 547664.9796359495 + } + ] + }, + "cliff_impact": { + "baseline": { + "cliff_gap": 19000000000.0, + "cliff_share": 0.0712 + }, + "reform": { + "cliff_gap": 20500000000.0, + "cliff_share": 0.0698 + } + } +} diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_state_ca_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_state_ca_2026.json new file mode 100644 index 000000000..bc742c76c --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/us_state_ca_2026.json @@ -0,0 +1,760 @@ +{ + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": { + "receipt": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2022-2036", + "forecast_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "scenario": "central", + "geography_kind": "county", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": [ + "2026" + ], + "runtime_versions": { + "policyengine": "6.10.0", + "policyengine-core": "3.20.0", + "policyengine-us": "1.715.2", + "spm-calculator": "0.4.1" + } + }, + "execution_count": 2 + }, + "reform": { + "receipt": { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2022-2036", + "forecast_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "scenario": "central", + "geography_kind": "county", + "geography_id": null, + "county_vintage": "2020", + "as_of": null, + "years": [ + "2026" + ], + "runtime_versions": { + "policyengine": "6.10.0", + "policyengine-core": "3.20.0", + "policyengine-us": "1.715.2", + "spm-calculator": "0.4.1" + } + }, + "execution_count": 2 + } + }, + "model_version": "1.715.2", + "data_version": "1.115.5", + "budget": { + "tax_revenue_impact": 89565497411.86987, + "state_tax_revenue_impact": -886897264.5463827, + "benefit_spending_impact": -83025600968.21567, + "budgetary_impact": 91206854377.84988, + "households": 136709977.562589, + "baseline_net_income": 12471939978137.047 + }, + "detailed_budget": { + "income_tax": { + "baseline": 339460802.8804418, + "reform": -383727084.8217116, + "difference": 211888331.35692486 + }, + "snap": { + "baseline": 213603467.2816758, + "reform": 162408034.22400618, + "difference": -683234259.4903889 + }, + "eitc": { + "baseline": -138660719.4174627, + "reform": -212936359.58925727, + "difference": 446024162.4749317 + }, + "ctc": { + "baseline": 989639125.8994855, + "reform": 898790946.1864872, + "difference": 88354094.85864165 + }, + "ssi": { + "baseline": -110291622.54829283, + "reform": -463518516.70134383, + "difference": -928151341.2142848 + }, + "state_income_tax": { + "baseline": -945110285.818362, + "reform": -70212275.80537581, + "difference": -363069744.29264516 + } + }, + "decile": { + "average": { + "1": -719.9104685957303, + "2": 2350.7367469697247, + "3": 154.51661487616985, + "4": 363.06216615899325, + "5": -1583.2595573096276, + "6": -2856.8515251553067, + "7": -1049.1424274330398, + "8": -2179.8156420812, + "9": 61.34307502320691, + "10": 2992.101409155312 + }, + "relative": { + "1": -0.0, + "2": -3.5e-9, + "3": 0.7967599214216395, + "4": 1e-12, + "5": 1e-12, + "6": -3.5e-9, + "7": -3.5e-9, + "8": -3.5e-9, + "9": -0.0, + "10": -3.5e-9 + } + }, + "inequality": { + "gini": { + "baseline": 0.5008410626306549, + "reform": 0.8315244897918123 + }, + "top_10_pct_share": { + "baseline": 0.3539242048687159, + "reform": 0.8828509185812531 + }, + "top_1_pct_share": { + "baseline": 0.8997005887566257, + "reform": 0.46101216488163765 + } + }, + "poverty": { + "poverty": { + "child": { + "baseline": 0.5677050704202443, + "reform": 0.9203304391919288 + }, + "adult": { + "baseline": 0.7237729538720183, + "reform": 0.48660855486158505 + }, + "senior": { + "baseline": 0.22181101099101086, + "reform": 0.324667243768898 + }, + "all": { + "baseline": 0.6995716380702436, + "reform": 0.16606968549412615 + } + }, + "deep_poverty": { + "child": { + "baseline": 0.9079404966260946, + "reform": 0.26813751289981635 + }, + "adult": { + "baseline": 0.9113778358680468, + "reform": 0.3095631249494607 + }, + "senior": { + "baseline": 0.957361711556158, + "reform": 0.7062058063676047 + }, + "all": { + "baseline": 0.5042488169833176, + "reform": 0.5177477561485521 + } + } + }, + "poverty_by_gender": { + "poverty": { + "male": { + "baseline": 0.6514143989667908, + "reform": 0.5879447117944846 + }, + "female": { + "baseline": 0.3118443245510002, + "reform": 0.20781847453792723 + } + }, + "deep_poverty": { + "male": { + "baseline": 0.5118916583552825, + "reform": 0.9341543591337786 + }, + "female": { + "baseline": 0.6232650867258723, + "reform": 0.0753753690740454 + } + } + }, + "poverty_by_race": { + "poverty": { + "white": { + "baseline": 0.8203999947120169, + "reform": 0.7259492874772981 + }, + "black": { + "baseline": 0.9076536209513208, + "reform": 0.1914027333041175 + }, + "hispanic": { + "baseline": 0.7447827242773541, + "reform": 0.058758896398655724 + }, + "other": { + "baseline": 0.6529099274345497, + "reform": 0.27309973233714935 + } + } + }, + "intra_decile": { + "deciles": { + "Gain more than 5%": [ + 0.22661652924476305, + 0.8754911714482378, + 0.10626598264552489, + 0.52236266535893, + 0.853943007184872, + 0.2448319779690169, + 0.2104789386956465, + 0.8805817593662799, + 0.42291764838969603, + 0.7169610989049753 + ], + "Gain less than 5%": [ + 0.03187307012674512, + 0.36235691130324066, + 0.17188099212573238, + 0.6727654414135418, + 0.08290317740457553, + 0.9545621653457477, + 0.025344714826901038, + 0.7294235074418041, + 0.02114486972315066, + 0.2556900540574426 + ], + "No change": [ + 0.8133543874022758, + 0.1571182886867749, + 0.1837388092509762, + 0.6914954260135907, + 0.38556588135256054, + 0.043160995797499324, + 0.9900015462028011, + 0.15142010875655154, + 0.03626899424341401, + 0.34420100553679467 + ], + "Lose less than 5%": [ + 0.6152394833248576, + 0.7424596231257796, + 0.11311490301807692, + 0.33721377319664525, + 0.03081085762668856, + 0.448653262492294, + 0.7659699365880781, + 0.7399466637207005, + 0.9020201583500368, + 0.7556621536750643 + ], + "Lose more than 5%": [ + 0.8624457763254278, + 0.7053451400507655, + 0.4727795120983127, + 0.22552757034788762, + 0.6608284986570139, + 0.3163059267282592, + 0.10204910504969822, + 0.44782186141915115, + 0.8747630413479673, + 0.12753646461878787 + ] + }, + "all": { + "Gain more than 5%": 0.5849556981106074, + "Gain less than 5%": 0.3929525501051674, + "No change": 0.5148026967641771, + "Lose less than 5%": 0.14382946397694396, + "Lose more than 5%": 0.9597311864871608 + } + }, + "wealth_decile": null, + "intra_wealth_decile": null, + "labor_supply_response": null, + "constituency_impact": null, + "local_authority_impact": null, + "congressional_district_impact": { + "districts": [ + { + "district": "CA-01", + "average_household_income_change": -1340.202080667208, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.4791004720574512, + "loser_percentage": 0.21601434789390284, + "no_change_percentage": 0.7926333582002464, + "population": 1615708.6039068555 + }, + { + "district": "CA-02", + "average_household_income_change": 49.8249585503725, + "relative_household_income_change": -0.0, + "winner_percentage": 0.7884035185216414, + "loser_percentage": 0.014121348940183798, + "no_change_percentage": 0.9327781318527646, + "population": 856382.2369167916 + }, + { + "district": "CA-03", + "average_household_income_change": -1420.3115763142403, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6939185190374807, + "loser_percentage": 0.43685591964096726, + "no_change_percentage": 0.9953730235896091, + "population": 1541402.9341196942 + }, + { + "district": "CA-04", + "average_household_income_change": -372.9236836466168, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.5401493571516298, + "loser_percentage": 0.6939154052422181, + "no_change_percentage": 0.6488161903021046, + "population": 1875821.4170999322 + }, + { + "district": "CA-05", + "average_household_income_change": -1907.1033848877832, + "relative_household_income_change": 0.0, + "winner_percentage": 0.1248771012471086, + "loser_percentage": 0.9279438631202989, + "no_change_percentage": 0.45312759400232583, + "population": 1104005.7499476979 + }, + { + "district": "CA-06", + "average_household_income_change": -628.3143043117749, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6651080998565223, + "loser_percentage": 0.4699420173904715, + "no_change_percentage": 0.8201443868492999, + "population": 149246.67448191857 + }, + { + "district": "CA-07", + "average_household_income_change": -1210.490429146224, + "relative_household_income_change": 0.6737943587360455, + "winner_percentage": 0.988819150103311, + "loser_percentage": 0.07092682363448932, + "no_change_percentage": 0.5601229852164192, + "population": 1117885.1846906256 + }, + { + "district": "CA-08", + "average_household_income_change": -447.9625165140804, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8875597159253288, + "loser_percentage": 0.40960169056048457, + "no_change_percentage": 0.3652205463834728, + "population": 902643.2559108599 + }, + { + "district": "CA-09", + "average_household_income_change": -456.8892622604417, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.2657046394559417, + "loser_percentage": 0.8341206393633971, + "no_change_percentage": 0.15894373296270192, + "population": 1431995.9281805663 + }, + { + "district": "CA-10", + "average_household_income_change": 1929.5672028067936, + "relative_household_income_change": 0.0, + "winner_percentage": 0.5504308983637247, + "loser_percentage": 0.7481760198039773, + "no_change_percentage": 0.22203302064179198, + "population": 1217802.7826345137 + }, + { + "district": "CA-11", + "average_household_income_change": 225.94800531849612, + "relative_household_income_change": -0.0, + "winner_percentage": 0.6696762455261636, + "loser_percentage": 0.626497070105428, + "no_change_percentage": 0.8336585978200555, + "population": 664756.392637095 + }, + { + "district": "CA-12", + "average_household_income_change": 1047.4084793697762, + "relative_household_income_change": -0.0, + "winner_percentage": 0.10327322906771608, + "loser_percentage": 0.06564394327349787, + "no_change_percentage": 0.8136958418340839, + "population": 1554037.175010373 + }, + { + "district": "CA-13", + "average_household_income_change": -500.4977834258955, + "relative_household_income_change": 0.6833517926685315, + "winner_percentage": 0.8986976344732958, + "loser_percentage": 0.9582801181193664, + "no_change_percentage": 0.40701465992468444, + "population": 1978219.2582351891 + }, + { + "district": "CA-14", + "average_household_income_change": 1845.7190615493287, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.4909118600646697, + "loser_percentage": 0.37556014706891894, + "no_change_percentage": 0.24720779013847893, + "population": 1752289.2094533443 + }, + { + "district": "CA-15", + "average_household_income_change": -1350.8297335560103, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7739166225598225, + "loser_percentage": 0.48317292375438614, + "no_change_percentage": 0.8369988075493334, + "population": 104347.25662033516 + }, + { + "district": "CA-16", + "average_household_income_change": -968.0935544244576, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.24859453119806696, + "loser_percentage": 0.39452527515181346, + "no_change_percentage": 0.8703856344378171, + "population": 1290870.9481231153 + }, + { + "district": "CA-17", + "average_household_income_change": -1415.5413513348383, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.049402104707172434, + "loser_percentage": 0.13958869843838762, + "no_change_percentage": 0.6502503344835922, + "population": 1822890.3670401648 + }, + { + "district": "CA-18", + "average_household_income_change": -83.73355781139668, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.7952183049458383, + "loser_percentage": 0.8814528015908482, + "no_change_percentage": 0.6013557819482931, + "population": 852164.9909683842 + }, + { + "district": "CA-19", + "average_household_income_change": 324.5580465373026, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.13879509668301637, + "loser_percentage": 0.5884124731718138, + "no_change_percentage": 0.13931078291129895, + "population": 1441535.0110863678 + }, + { + "district": "CA-20", + "average_household_income_change": -470.5363903850697, + "relative_household_income_change": 0.0, + "winner_percentage": 0.2644210278517495, + "loser_percentage": 0.3261696595241047, + "no_change_percentage": 0.24976541736485114, + "population": 1911351.331835094 + }, + { + "district": "CA-21", + "average_household_income_change": -315.60128160724776, + "relative_household_income_change": -0.0, + "winner_percentage": 0.13353777782594412, + "loser_percentage": 0.09483912882924739, + "no_change_percentage": 0.09393882890552552, + "population": 50634.006287708646 + }, + { + "district": "CA-22", + "average_household_income_change": -212.57815402313707, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6553232690791773, + "loser_percentage": 0.12652804183979316, + "no_change_percentage": 0.5386586184955431, + "population": 648553.7647646856 + }, + { + "district": "CA-23", + "average_household_income_change": -24.53294730317035, + "relative_household_income_change": -0.0, + "winner_percentage": 0.1815753145809701, + "loser_percentage": 0.857494044138571, + "no_change_percentage": 0.20162091933172333, + "population": 1488396.6527884605 + }, + { + "district": "CA-24", + "average_household_income_change": 1028.2245590144817, + "relative_household_income_change": 0.6038979940926733, + "winner_percentage": 0.8738689850685158, + "loser_percentage": 0.7742488313963415, + "no_change_percentage": 0.7907254914134034, + "population": 15645.68858814286 + }, + { + "district": "CA-25", + "average_household_income_change": -144.4811563353863, + "relative_household_income_change": 0.0634914219225714, + "winner_percentage": 0.17529714744120328, + "loser_percentage": 0.7114876004555625, + "no_change_percentage": 0.2529059496820111, + "population": 1393466.1836204943 + }, + { + "district": "CA-26", + "average_household_income_change": -745.5308811125443, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.15617347739198484, + "loser_percentage": 0.726143113529342, + "no_change_percentage": 0.9904837928341851, + "population": 1635204.4120896044 + }, + { + "district": "CA-27", + "average_household_income_change": 1212.2940882841444, + "relative_household_income_change": 0.49005924871725615, + "winner_percentage": 0.09856340695484134, + "loser_percentage": 0.6011795331541527, + "no_change_percentage": 0.5082125559059094, + "population": 1469145.2283251747 + }, + { + "district": "CA-28", + "average_household_income_change": 1868.445322419666, + "relative_household_income_change": 0.31015720435692795, + "winner_percentage": 0.5257866588071927, + "loser_percentage": 0.5606768219884671, + "no_change_percentage": 0.06372394872053855, + "population": 1629376.6398093402 + }, + { + "district": "CA-29", + "average_household_income_change": 1907.390809098553, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.6579595952720738, + "loser_percentage": 0.42027314087455103, + "no_change_percentage": 0.8679698934861128, + "population": 1303425.8793790916 + }, + { + "district": "CA-30", + "average_household_income_change": 506.1883573034174, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.5050432802840407, + "loser_percentage": 0.94705073550179, + "no_change_percentage": 0.6798941392052675, + "population": 1411300.6359731662 + }, + { + "district": "CA-31", + "average_household_income_change": -116.47956021349427, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.07035418767844082, + "loser_percentage": 0.024948612219243826, + "no_change_percentage": 0.6615002783679655, + "population": 84079.98453165463 + }, + { + "district": "CA-32", + "average_household_income_change": -897.6705758518216, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7237596916187159, + "loser_percentage": 0.45599214581860004, + "no_change_percentage": 0.23643698095484955, + "population": 770813.9915391634 + }, + { + "district": "CA-33", + "average_household_income_change": -52.86141551185253, + "relative_household_income_change": 0.0, + "winner_percentage": 0.03743134796401293, + "loser_percentage": 0.1728476844123371, + "no_change_percentage": 0.37302769509832234, + "population": 1209181.9329632234 + }, + { + "district": "CA-34", + "average_household_income_change": -1136.3743609334404, + "relative_household_income_change": 0.44974025060838185, + "winner_percentage": 0.6680286879169739, + "loser_percentage": 0.8589059159576242, + "no_change_percentage": 0.699025972338484, + "population": 1365950.6330220753 + }, + { + "district": "CA-35", + "average_household_income_change": -1018.775031530851, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.305951912810726, + "loser_percentage": 0.3818494888010229, + "no_change_percentage": 0.524231157926504, + "population": 1422756.954866777 + }, + { + "district": "CA-36", + "average_household_income_change": 1636.3690795417276, + "relative_household_income_change": -0.0, + "winner_percentage": 0.31173263397561823, + "loser_percentage": 0.39122298893643304, + "no_change_percentage": 0.14542152800186026, + "population": 1106487.3959916614 + }, + { + "district": "CA-37", + "average_household_income_change": -1681.9329702146072, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.7423554937763153, + "loser_percentage": 0.46234896766399936, + "no_change_percentage": 0.1082182749423255, + "population": 1912003.3408171246 + }, + { + "district": "CA-38", + "average_household_income_change": 815.8015737913117, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.3719751435635782, + "loser_percentage": 0.5816750130178863, + "no_change_percentage": 0.18881197468094058, + "population": 163990.98860082752 + }, + { + "district": "CA-39", + "average_household_income_change": -1885.6368564930008, + "relative_household_income_change": -0.0, + "winner_percentage": 0.8997208675828816, + "loser_percentage": 0.5070490702977853, + "no_change_percentage": 0.23838487713015877, + "population": 1376329.3814273335 + }, + { + "district": "CA-40", + "average_household_income_change": 864.7549563346893, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.4669656708961981, + "loser_percentage": 0.4368809942954668, + "no_change_percentage": 0.6170923684966161, + "population": 561254.8689483237 + }, + { + "district": "CA-41", + "average_household_income_change": -1302.6132980683976, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.09895846388084228, + "loser_percentage": 0.9751742106734704, + "no_change_percentage": 0.21518936033827352, + "population": 701197.8299980335 + }, + { + "district": "CA-42", + "average_household_income_change": 517.8454200848881, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.018987857363210625, + "loser_percentage": 0.5012020362915018, + "no_change_percentage": 0.03926851151996702, + "population": 1215724.072474778 + }, + { + "district": "CA-43", + "average_household_income_change": 801.631195832408, + "relative_household_income_change": 0.0, + "winner_percentage": 0.9415549378425223, + "loser_percentage": 0.14788159798472278, + "no_change_percentage": 0.9728397004974083, + "population": 380605.45107877883 + }, + { + "district": "CA-44", + "average_household_income_change": -517.7072914107455, + "relative_household_income_change": -0.0, + "winner_percentage": 0.1349522811977817, + "loser_percentage": 0.7917141311763941, + "no_change_percentage": 0.8223465638768986, + "population": 1058831.0784220085 + }, + { + "district": "CA-45", + "average_household_income_change": -1803.3634630249749, + "relative_household_income_change": -3.5e-9, + "winner_percentage": 0.8825392168156697, + "loser_percentage": 0.24432321805596258, + "no_change_percentage": 0.5748886823831034, + "population": 1647570.443801504 + }, + { + "district": "CA-46", + "average_household_income_change": 338.45063820962815, + "relative_household_income_change": 0.0, + "winner_percentage": 0.6587134494041641, + "loser_percentage": 0.07301193535656736, + "no_change_percentage": 0.43578737154362135, + "population": 1665433.0603320487 + }, + { + "district": "CA-47", + "average_household_income_change": 1956.4619290612816, + "relative_household_income_change": -0.0, + "winner_percentage": 0.0596454627614903, + "loser_percentage": 0.8152030671131772, + "no_change_percentage": 0.6065200458135751, + "population": 1240564.5324497477 + }, + { + "district": "CA-48", + "average_household_income_change": -115.56590559409318, + "relative_household_income_change": 1e-12, + "winner_percentage": 0.1527065431306417, + "loser_percentage": 0.1534710999811163, + "no_change_percentage": 0.28797260893872434, + "population": 1279944.9805484125 + }, + { + "district": "CA-49", + "average_household_income_change": -1963.029387886932, + "relative_household_income_change": 0.0, + "winner_percentage": 0.1863473319393073, + "loser_percentage": 0.09815282134718561, + "no_change_percentage": 0.8283105804754989, + "population": 720381.8897881922 + }, + { + "district": "CA-50", + "average_household_income_change": -590.5154925954253, + "relative_household_income_change": 0.1620999651934779, + "winner_percentage": 0.4950585460308846, + "loser_percentage": 0.680757133582871, + "no_change_percentage": 0.653173233564723, + "population": 316374.24278914207 + }, + { + "district": "CA-51", + "average_household_income_change": -1583.5641255176856, + "relative_household_income_change": 0.9308651041879465, + "winner_percentage": 0.2504366070659293, + "loser_percentage": 0.1989707551215727, + "no_change_percentage": 0.19353466278177012, + "population": 1908012.2191396358 + }, + { + "district": "CA-52", + "average_household_income_change": -306.0961640384328, + "relative_household_income_change": -0.0, + "winner_percentage": 0.2137151770500293, + "loser_percentage": 0.04047780281107205, + "no_change_percentage": 0.35227293973175733, + "population": 1065100.5898863673 + } + ] + }, + "cliff_impact": null +} diff --git a/libs/policyengine-simulation-persistence/tests/result_support.py b/libs/policyengine-simulation-persistence/tests/result_support.py new file mode 100644 index 000000000..1fec3bed9 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/result_support.py @@ -0,0 +1,111 @@ +"""Samples and builders for the result table tests. + +The sample under ``fixtures/economy_results`` holds one complete economy +result per case the plan names: US national, state and congressional district; +UK national, nation, constituency and local authority; and a budget-window +year. Each is generated, sized like a production result, with values of +varied sign and magnitude (negative zero included), because no stored real +results could be read without starting new production calculations. An +exported real result can replace any file without a code change. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_persistence import ( + EconomyResultLabels, + GeographyLabel, + NewReportRun, + RunTrigger, +) +from report_support import economy_report, report_store +from run_support import economy_run +from simulation_support import economy_simulation, simulation_store +from transition_support import clocked_stores + +if TYPE_CHECKING: + from policyengine_simulation_persistence import ReportRunRecord + +SAMPLE_DIRECTORY = Path(__file__).parent / "fixtures" / "economy_results" +SAMPLE_NAMES = sorted(path.stem for path in SAMPLE_DIRECTORY.glob("*.json")) + + +def load_sample(name: str) -> SingleYearMacroOutput: + """Return one sample result, validated.""" + return SingleYearMacroOutput.model_validate( + json.loads((SAMPLE_DIRECTORY / f"{name}.json").read_text(encoding="utf-8")) + ) + + +def labels_for(result: SingleYearMacroOutput) -> EconomyResultLabels: + """Return the region catalog's labels for a result, taken from the result.""" + + def labels(records, code_field: str, name_field: str): + return { + getattr(record, code_field): GeographyLabel( + name=getattr(record, name_field), x=record.x, y=record.y + ) + for record in (records.root if records is not None else []) + } + + return EconomyResultLabels( + model_version=result.model_version, + data_version=result.data_version, + constituencies=labels( + result.constituency_impact, "constituency_code", "constituency_name" + ), + local_authorities=labels( + result.local_authority_impact, + "local_authority_code", + "local_authority_name", + ), + ) + + +def comparison_ready(stores, database, rows, *, baseline=None) -> ReportRunRecord: + """Return an economy report run whose comparison step is running. + + Both simulation runs have succeeded. Pass ``baseline`` to reuse an + existing current-law simulation, as a second report on it would. + """ + simulations = simulation_store(database) + baseline = baseline or simulations.create(economy_simulation(rows, policy_id=None)) + reform = simulations.create(economy_simulation(rows)) + report = report_store(database).create(economy_report(baseline, reform)) + runs = [ + stores.runs.start_simulation_run(economy_run(simulation, rows)).run + for simulation in (baseline, reform) + ] + for run in runs: + if run.status.value == "succeeded": + continue + call_id = f"fc-{run.id}" + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=call_id, first_check_at=stores.clock() + ) + stores.transitions.complete_simulation_call(call_id, lambda *_: None) + report_run = stores.runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=runs[0].id, + reform_simulation_run_id=runs[1].id, + trigger=RunTrigger.INITIAL, + ) + ) + stores.transitions.claim_comparison_dispatch(report_run.id) + stores.transitions.record_comparison_dispatched( + report_run.id, + modal_call_id=f"fc-comparison-{report_run.id}", + first_check_at=stores.clock(), + ) + return report_run + + +def result_stores(database): + """Return the run store and transitions every result test uses.""" + return clocked_stores(database) diff --git a/libs/policyengine-simulation-persistence/tests/test_economy_results.py b/libs/policyengine-simulation-persistence/tests/test_economy_results.py new file mode 100644 index 000000000..80b7c8118 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_economy_results.py @@ -0,0 +1,163 @@ +"""Economy results stored as rows and rebuilt exactly.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_persistence import ( + EconomyComparisonRecorder, + EconomyResultLabels, + check_storable, + create_simulation_engine, + read_economy_comparison, +) +from pydantic import ValidationError +from result_support import ( + SAMPLE_NAMES, + comparison_ready, + labels_for, + load_sample, + result_stores, +) +from simulation_support import simulation_store + +pytestmark = pytest.mark.postgres + +RECORDER = EconomyComparisonRecorder() + + +def _write(stores, report_run, result) -> None: + assert stores.transitions.complete_comparison( + report_run.id, + lambda connection, record: RECORDER.write(connection, record, result), + ) + + +def _read(database, report_run_id, labels): + engine = create_simulation_engine(database.runtime_url) + try: + with engine.connect() as connection: + return read_economy_comparison(connection, report_run_id, labels) + finally: + engine.dispose() + + +def test_the_sample_covers_every_case_the_plan_names() -> None: + assert SAMPLE_NAMES == [ + "uk_constituency_2026", + "uk_local_authority_2026", + "uk_nation_scotland_2026", + "uk_national_2026", + "us_budget_window_year_2030", + "us_congressional_district_ca_12_2026", + "us_national_2026", + "us_state_ca_2026", + ] + + +@pytest.mark.parametrize("sample_name", SAMPLE_NAMES) +def test_a_stored_result_is_rebuilt_exactly( + migrated_database, public_api_rows, sample_name +) -> None: + result = load_sample(sample_name) + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + _write(stores, report_run, RECORDER.validate(result.model_dump(mode="json"))) + + rebuilt = _read(migrated_database, report_run.id, labels_for(result)) + assert rebuilt.model_dump_json() == result.model_dump_json() + assert rebuilt == result + + +def test_a_reused_baseline_keeps_its_figures_once( + migrated_database, public_api_rows +) -> None: + first = load_sample("us_state_ca_2026") + second = first.model_copy( + update={"budget": first.budget.model_copy(update={"budgetary_impact": 1.5})} + ) + stores = result_stores(migrated_database) + first_run = comparison_ready(stores, migrated_database, public_api_rows) + _write(stores, first_run, first) + baseline = stores.runs.get_simulation_run(first_run.baseline_simulation_run_id) + second_run = comparison_ready( + stores, + migrated_database, + public_api_rows, + baseline=simulation_store(migrated_database).get(baseline.simulation_id), + ) + assert second_run.baseline_simulation_run_id == first_run.baseline_simulation_run_id + + _write(stores, second_run, second) + + labels = labels_for(first) + assert _read(migrated_database, first_run.id, labels) == first + assert _read(migrated_database, second_run.id, labels) == second + + +def test_reading_needs_a_succeeded_comparison( + migrated_database, public_api_rows +) -> None: + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + with pytest.raises(LookupError, match="no succeeded economy comparison"): + _read( + migrated_database, + report_run.id, + EconomyResultLabels(model_version="1", data_version="1"), + ) + + +def test_reading_needs_a_label_for_every_geography( + migrated_database, public_api_rows +) -> None: + result = load_sample("uk_nation_scotland_2026") + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + _write(stores, report_run, result) + labels = labels_for(result) + missing = EconomyResultLabels( + model_version=labels.model_version, + data_version=labels.data_version, + constituencies=dict(list(labels.constituencies.items())[1:]), + local_authorities=labels.local_authorities, + ) + + with pytest.raises(LookupError, match="no label for S14"): + _read(migrated_database, report_run.id, missing) + + +def _with(result, **sections): + return result.model_copy(update=sections) + + +def test_results_the_tables_cannot_hold_exactly_are_refused() -> None: + result = load_sample("uk_national_2026") + reordered = result.decile.model_copy( + update={"relative": dict(reversed(result.decile.relative.items()))} + ) + no_shares = result.intra_decile.model_copy( + update={"deciles": {**result.intra_decile.deciles, "No change": []}} + ) + mixed = result.constituency_impact.model_copy( + update={ + "root": [ + *result.constituency_impact.root, + result.local_authority_impact.root[0], + ] + } + ) + + for refused, message in ( + (_with(result, decile=reordered), "decile"), + (_with(result, intra_decile=no_shares), "intra_decile"), + (_with(result, constituency_impact=mixed), "constituency_impact"), + ): + with pytest.raises(ValueError, match=message): + check_storable(refused) + + +def test_the_recorder_refuses_output_that_is_not_a_result() -> None: + with pytest.raises(ValidationError): + RECORDER.validate({"budget": {"budgetary_impact": "much"}}) diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py index 4a6f42383..40b498110 100644 --- a/libs/policyengine-simulation-persistence/tests/test_schema.py +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -121,4 +121,7 @@ def test_importing_the_package_registers_every_table() -> None: "simulation.reports", "simulation.simulation_runs", "simulation.report_runs", + "simulation.poverty_rates", + "simulation.budget_impacts", + "simulation.local_authority_impacts", } <= package_tables diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock index b007a60ff..f19c0f00e 100644 --- a/libs/policyengine-simulation-persistence/uv.lock +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -16,6 +16,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -49,6 +160,247 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "fastapi" +version = "0.159.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/2aa84799e6920216f8aa2866d3b43daa20f7060dcb6efc8f0449e8be0ab0/google_api_core-2.42.0.tar.gz", hash = "sha256:82cf5daa2ef1b456d4e29ff1de1a5c2995c7be3ccf4fc608184326e03390c1ee", size = 273100, upload-time = "2026-10-08T18:12:37.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/ca/fb2a5b38366bcb12990c80384b920f04b968fd834cf98be67d306c374612/google_api_core-2.42.0-py3-none-any.whl", hash = "sha256:b1bdf4f72dc4f910736ce4ba49038352effbbc309579215107649b22973a1317", size = 221364, upload-time = "2026-10-08T18:12:04.618Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/b9/4b2528f30114b106e3c3a7298ba38d85e682fd1f83fe7c4a5b77c7677082/google_auth-2.62.0.tar.gz", hash = "sha256:0bef0ce54bdf9ce226c5d66e4264413bd918141c31bbe49fb52eac882f513d69", size = 398180, upload-time = "2026-10-12T19:20:48.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/87fe9b7203ec2e56ea6a57c75e6d632eaf9487c7f7978cdad1d801bee0af/google_auth-2.62.0-py3-none-any.whl", hash = "sha256:4ff4319aeb4ad128409759d397a9fcafad126d0031d241cc0dd6b9a00b43e3f3", size = 267911, upload-time = "2026-10-12T19:20:46.355Z" }, +] + +[[package]] +name = "google-cloud-appengine-logging" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/34/aec7eca310c84a106a22dfaad076766c2e2ebe0389bdb88270a22c807f9d/google_cloud_appengine_logging-1.11.0.tar.gz", hash = "sha256:ce053792df98fde4790c5f070176088c9ebf0e55a37c754c8f8e11b28ddb4d73", size = 21678, upload-time = "2026-10-01T18:14:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/a8/da5d9865b0e1ce53194eb1052484303f86e94cd87f3b6d49511690f70ea6/google_cloud_appengine_logging-1.11.0-py3-none-any.whl", hash = "sha256:4d70953a0f35d0295256d977e7e3962242674d64c72fcb3e48b10a1cbf4f9b54", size = 20251, upload-time = "2026-10-01T18:08:00.913Z" }, +] + +[[package]] +name = "google-cloud-audit-log" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/91/e2a4ed95e790564b807a61fd54d90aa6bf2d3a9474bc5197651acf0cad13/google_cloud_audit_log-0.6.2.tar.gz", hash = "sha256:ea5dc892b1858d345aef0c5563a728534fa875bd0388765391574d3c57fd8f0e", size = 41010, upload-time = "2026-08-25T19:18:29.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/f3/f24fba2cb01ad41615d55335306642e3ddf3a5c3cdda6b44754d37bd650e/google_cloud_audit_log-0.6.2-py3-none-any.whl", hash = "sha256:1f19bdf2586cfdb72054eec769ede153a037988b73a7d3b6d266f672ab426c00", size = 40382, upload-time = "2026-08-24T21:55:11.849Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/71/d6081acadf55d4233271c39860f0d140ef61fbdb4bceb2075e9c2905d947/google_cloud_core-2.8.0.tar.gz", hash = "sha256:365f8e4518ae81c8101b8dea5fc1c32a960badedb8b511f19db2843cbbd285d2", size = 36605, upload-time = "2026-09-29T19:25:59.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/ed/1b09640a565e5d34d4e517463f65a67a11f9dcdeb6b6e8220f2d06aaf489/google_cloud_core-2.8.0-py3-none-any.whl", hash = "sha256:e235b0952f7ffe7b9c71a4cf96b506d9cfb557e22557c412f0df9b7068b5d007", size = 31048, upload-time = "2026-09-29T19:25:35.361Z" }, +] + +[[package]] +name = "google-cloud-logging" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-appengine-logging" }, + { name = "google-cloud-audit-log" }, + { name = "google-cloud-core" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/5b/fbb367f8bb05b3e968048686f440181765a8681c3a07cfa901814f9280e1/google_cloud_logging-3.17.0.tar.gz", hash = "sha256:d7343f2b82b4f9e9244bfc8aba7ad034c415f9bbb405ed28d1125aa37d270b72", size = 292071, upload-time = "2026-10-01T18:17:01.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/53/911cbf97517123350292ac6edba7d130ee44acccfd3e2a36f6807f32da14/google_cloud_logging-3.17.0-py3-none-any.whl", hash = "sha256:da2819da683a5135e21c2853ff2c86040786cd5521c3f71424755c110cf36fe2", size = 234020, upload-time = "2026-10-01T18:10:50.73Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b9/6b29500a1c581ff4d77fd83c6568d068bee06f1b139fb6eb0a4f2d4bce8a/googleapis_common_protos-1.75.5-py3-none-any.whl", hash = "sha256:d7285525c23039db98f2463e6d5a4f9b958b94d497f03a844ece3259c4e72d5d", size = 307737, upload-time = "2026-09-29T19:25:48.735Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/d0/fa5bdd5f3f421bb68dc6dc162e9caaf942897ca41ce7255b524723c80f0b/grpc_google_iam_v1-0.14.5.tar.gz", hash = "sha256:07fd3a9fafb586588e771831fbfc8f6597050181d0c3b45e039d18b8fdc1aab5", size = 23736, upload-time = "2026-08-06T06:24:54.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ab/be3ad0d46cffe35fd1e7cc3f9947edd6cb3c552229de3be2742f15f7ea47/grpc_google_iam_v1-0.14.5-py3-none-any.whl", hash = "sha256:0f5e680b20aa0a9441e68c769da04d94d70fca4e43751a82d8abb8aa6a7181ca", size = 32674, upload-time = "2026-08-06T06:23:49.467Z" }, +] + +[[package]] +name = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/51/40f99701adb01d4e5316a2aaf13838da1a24d5c879cd8c95156d7c364454/grpcio-1.84.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:209414080da8c20af94df1395b635da52dd57b5edc9e917e1deca0dc1c4bb55e", size = 6427619, upload-time = "2026-09-14T06:58:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/ed8e22a1237e6b2be6ef4f221d074a5b0e0dd8a0da8c944c04aea731f0eb/grpcio-1.84.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e41c3993eee896c617dbd8a505085d28b6e84a0445ed9a1f40f95808473cf678", size = 12336549, upload-time = "2026-09-14T06:58:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/00165b05cd73f45996748ea67ce9e55d08936f2fea94a7fd8541cc2d0e54/grpcio-1.84.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fff5ef3fe1bba7d6147e5f19e01e5e122ac2c076486887ddcb8d42e663400fbe", size = 6989458, upload-time = "2026-09-14T06:58:11.884Z" }, + { url = "https://files.pythonhosted.org/packages/26/38/d0486230e684d916f97429a53041db88410e662a38f2a8d09e2d90375840/grpcio-1.84.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8c62888c3e49debf37ad9773e3c02f77b0c1e811f8fb0962f2b6c3bbab5b97a", size = 7757778, upload-time = "2026-09-14T06:58:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/da/56/548a643decb059ca244499c675ae2c13a15f523ba94592c2774bd80a13c1/grpcio-1.84.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:986e9751d416d7a6eaa2fecdac38da63153d63a4b340ba7d624889c490451500", size = 7159572, upload-time = "2026-09-14T06:58:17.87Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/42caac81a79ec680f1f7a8eaf7ca90d2f93936ce0c3a073141ba96757f77/grpcio-1.84.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5933a052946873d01a42119a05420d669bdca436aeba2d1851988ccb12b421c0", size = 7710547, upload-time = "2026-09-14T06:58:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/57/a4/828ad990b2410fee0a55cc73aa1bf98eb5b911c54847374ef4f24b9e877b/grpcio-1.84.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e094dd21f077af8194923fc263cad872eaa1802bb0156fd7e5ae18e99cd86715", size = 8761519, upload-time = "2026-09-14T06:58:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1f91af098919eaf5d80d5a61126ad9fae074e5190c25a3014ce1d8d0d890/grpcio-1.84.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08735e3d08d24ab3132cf87e2e5dea8746cabcc7d676c2b0b7362f195feef9d9", size = 8121424, upload-time = "2026-09-14T06:58:27.006Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8f/77fd4a7a913b636785479922349c4cb98d94d05d15652e556b3ca0df6663/grpcio-1.84.0-cp313-cp313-win32.whl", hash = "sha256:70bb4ce8be0c5606bec259cbd7152374470396413b7863a658a08c849e6b29ff", size = 4477974, upload-time = "2026-09-14T06:58:29.528Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/1fa59ddbfc8898e5518d1447e46f771f387f0ed6132ad531395338e51a5c/grpcio-1.84.0-cp313-cp313-win_amd64.whl", hash = "sha256:b61692f0069b3eee2fc8a3a1b7f6c044df9e03fede6ce69b3ca832e1c39f26c5", size = 5255326, upload-time = "2026-09-14T06:58:31.781Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/45/f80309cdb6a7dbf8f65e2082dd2ddc9797ba7180516a73c54d966ba632c4/grpcio_status-1.84.0.tar.gz", hash = "sha256:5caf28ba7184b81f618b5f7f094859fd2541bf429d2189bbbcd715c9c2cdcee2", size = 14015, upload-time = "2026-09-14T07:10:29.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/c4/3a77e4273e866b1b0c412afd80882e95941a37170032b5109d847c501124/grpcio_status-1.84.0-py3-none-any.whl", hash = "sha256:0c182ca0d6e60acbfd0e14499cf39a155e4827a1c3fd9f7638e49af15a74c30a", size = 14639, upload-time = "2026-09-14T07:10:15.175Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.1" @@ -107,6 +459,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/00/a82af0be959dc58495740b169c6669a86e0811f6cd353a01eda34d255db3/opentelemetry_exporter_otlp_proto_grpc-1.45.1.tar.gz", hash = "sha256:3b3dcfbfdcb4e35149fcf309972282054b45228f5c10547d0095d6578510a9a0", size = 25997, upload-time = "2026-10-06T17:33:05.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/46/2d1da202f1e17c81aae7efcf702898d524b46709e4d3e2bf1f7f8ca8fbc6/opentelemetry_exporter_otlp_proto_grpc-1.45.1-py3-none-any.whl", hash = "sha256:e42ecb789d2fc5d8145e3dadc3e2991c9f18cd166d7c7514e234702540274b76", size = 19492, upload-time = "2026-10-06T17:32:42.838Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -125,11 +571,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] +[[package]] +name = "policyengine-observability" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d4/9c85331075d61e4eec1c7aec905aee6221006c5bd81474ebb66f1c7d9a8d/policyengine_observability-3.0.5.tar.gz", hash = "sha256:de70cb5cbb6944cbad40eaa566c0fab661b2dc4747c9b3f874d7a096eedf7363", size = 129269, upload-time = "2026-10-27T20:37:05.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/c9/e6da2a9c35a6cd533708ad282091d57212fd8784d4c668102727f23b8d50/policyengine_observability-3.0.5-py3-none-any.whl", hash = "sha256:ff566284f3a742bcafd2f1738dd8de54990f125c591e3dab351750982eab79dc", size = 42574, upload-time = "2026-10-27T20:37:03.684Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] +google = [ + { name = "google-auth" }, + { name = "google-cloud-logging" }, +] +otlp-grpc = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, +] + +[[package]] +name = "policyengine-simulation-contract" +version = "0.1.0" +source = { editable = "../policyengine-simulation-contract" } +dependencies = [ + { name = "packaging" }, + { name = "policyengine-simulation-observability" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, + { name = "packaging", specifier = ">=24" }, + { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["modal", "test", "build"] + +[[package]] +name = "policyengine-simulation-observability" +version = "0.1.0" +source = { editable = "../policyengine-simulation-observability" } +dependencies = [ + { name = "fastapi" }, + { name = "policyengine-observability", extra = ["fastapi", "google", "otlp-grpc"] }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx2", marker = "extra == 'test'" }, + { name = "policyengine-observability", extras = ["fastapi", "google", "otlp-grpc"], specifier = ">=3.0.4,<4" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["test", "build"] + [[package]] name = "policyengine-simulation-persistence" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "policyengine-simulation-contract" }, { name = "psycopg", extra = ["binary"] }, { name = "sqlalchemy" }, ] @@ -152,6 +671,7 @@ test = [ requires-dist = [ { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.17.1,<2" }, { name = "alembic", marker = "extra == 'test'", specifier = ">=1.17.1,<2" }, + { name = "policyengine-simulation-contract", editable = "../policyengine-simulation-contract" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, @@ -161,6 +681,33 @@ requires-dist = [ ] provides-extras = ["migrations", "test", "build"] +[[package]] +name = "proto-plus" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/70/783e33ffbb4466cc154a94f79b869b92a451e2bd45605054e68ff68b7af6/proto_plus-1.29.0.tar.gz", hash = "sha256:cfb4e62ad7e13dd18f346cabbda00cab39930d36a05791fd81ddb074d6ee884f", size = 58592, upload-time = "2026-09-29T19:26:15.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/05/a3ef5b1161498e7b5a2a60288a9cf41b9149e94555423a43ec536737ac1a/proto_plus-1.29.0-py3-none-any.whl", hash = "sha256:8acd070469a7aaf43f440b022ef9757c8cac1a9f866e933f59ae98669ddc6c8b", size = 50786, upload-time = "2026-09-29T19:25:50.409Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + [[package]] name = "psycopg" version = "3.3.6" @@ -196,6 +743,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -266,6 +884,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.6" @@ -288,6 +921,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/69/c75534e336b06b3fd3f76de73d89736510800e60c77c58d4e4ce18389018/sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe", size = 2063055, upload-time = "2026-10-21T18:42:10.234Z" }, ] +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -297,6 +942,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "tzdata" version = "2026.5" @@ -305,3 +962,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce7 wheels = [ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] diff --git a/migrations/versions/0004_economy_result_tables.py b/migrations/versions/0004_economy_result_tables.py new file mode 100644 index 000000000..6b490d218 --- /dev/null +++ b/migrations/versions/0004_economy_result_tables.py @@ -0,0 +1,383 @@ +"""Add the economy result tables. + +Every economy result is stored as rows, each section of today's result model +in its own table. A simulation's own figures (poverty rates, inequality, cliff +exposure) belong to its simulation run; the comparison of a baseline and a +reform (budget, programs, deciles, labour supply, geographies) belongs to the +report run. Each report run also records the optional sections its result +holds and its SPM provenance. Rows are written in the transaction that marks a +run succeeded; the default privileges let the runtime role read and insert +them. Nothing writes them yet. + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-10-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the result tables and the report run's result columns.""" + op.create_table( + "cliff_impacts", + sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("cliff_gap", sa.Double(), nullable=False), + sa.Column("cliff_share", sa.Double(), nullable=False), + sa.ForeignKeyConstraint( + ["simulation_run_id"], + ["simulation.simulation_runs.id"], + name="fk_cliff_impacts_simulation_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("simulation_run_id", name=op.f("pk_cliff_impacts")), + schema="simulation", + ) + op.create_table( + "inequality", + sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("gini", sa.Double(), nullable=False), + sa.Column("top_10_pct_share", sa.Double(), nullable=False), + sa.Column("top_1_pct_share", sa.Double(), nullable=False), + sa.ForeignKeyConstraint( + ["simulation_run_id"], + ["simulation.simulation_runs.id"], + name="fk_inequality_simulation_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("simulation_run_id", name=op.f("pk_inequality")), + schema="simulation", + ) + op.create_table( + "poverty_rates", + sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("measure", sa.Text(), nullable=False), + sa.Column("group_dimension", sa.Text(), nullable=False), + sa.Column("group_value", sa.Text(), nullable=False), + sa.Column("rate", sa.Double(), nullable=False), + sa.CheckConstraint( + "(group_dimension = 'age' " + "AND group_value IN ('child', 'adult', 'senior', 'all')) " + "OR (group_dimension = 'gender' AND group_value IN ('male', 'female')) " + "OR (group_dimension = 'race' " + "AND group_value IN ('white', 'black', 'hispanic', 'other'))", + name=op.f("ck_poverty_rates_group"), + ), + sa.CheckConstraint( + "measure IN ('poverty', 'deep_poverty')", + name=op.f("ck_poverty_rates_measure"), + ), + sa.ForeignKeyConstraint( + ["simulation_run_id"], + ["simulation.simulation_runs.id"], + name="fk_poverty_rates_simulation_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "simulation_run_id", + "measure", + "group_dimension", + "group_value", + name=op.f("pk_poverty_rates"), + ), + schema="simulation", + ) + op.create_table( + "budget_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("budgetary_impact", sa.Double(), nullable=False), + sa.Column("tax_revenue_impact", sa.Double(), nullable=False), + sa.Column("state_tax_revenue_impact", sa.Double(), nullable=False), + sa.Column("benefit_spending_impact", sa.Double(), nullable=False), + sa.Column("households", sa.Double(), nullable=False), + sa.Column("baseline_net_income", sa.Double(), nullable=False), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_budget_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("report_run_id", name=op.f("pk_budget_impacts")), + schema="simulation", + ) + op.create_table( + "congressional_district_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("district", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("average_household_income_change", sa.Double(), nullable=False), + sa.Column("relative_household_income_change", sa.Double(), nullable=False), + sa.Column("winner_percentage", sa.Double(), nullable=False), + sa.Column("loser_percentage", sa.Double(), nullable=False), + sa.Column("no_change_percentage", sa.Double(), nullable=False), + sa.Column("population", sa.Double(), nullable=False), + sa.CheckConstraint( + "position >= 0", name=op.f("ck_congressional_district_impacts_position") + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_congressional_district_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", "district", name=op.f("pk_congressional_district_impacts") + ), + sa.UniqueConstraint( + "report_run_id", + "position", + name=op.f("uq_congressional_district_impacts_report_run_id_position"), + ), + schema="simulation", + ) + op.create_table( + "constituency_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("constituency_code", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("average_household_income_change", sa.Double(), nullable=False), + sa.Column("relative_household_income_change", sa.Double(), nullable=False), + sa.Column("population", sa.Double(), nullable=False), + sa.CheckConstraint( + "position >= 0", name=op.f("ck_constituency_impacts_position") + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_constituency_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", "constituency_code", name=op.f("pk_constituency_impacts") + ), + sa.UniqueConstraint( + "report_run_id", + "position", + name=op.f("uq_constituency_impacts_report_run_id_position"), + ), + schema="simulation", + ) + op.create_table( + "decile_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("measure", sa.Text(), nullable=False), + sa.Column("decile", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("average_change", sa.Double(), nullable=False), + sa.Column("relative_change", sa.Double(), nullable=False), + sa.CheckConstraint( + "measure IN ('income', 'wealth')", name=op.f("ck_decile_impacts_measure") + ), + sa.CheckConstraint("position >= 0", name=op.f("ck_decile_impacts_position")), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_decile_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", "measure", "decile", name=op.f("pk_decile_impacts") + ), + sa.UniqueConstraint( + "report_run_id", + "measure", + "position", + name=op.f("uq_decile_impacts_report_run_id_measure_position"), + ), + schema="simulation", + ) + op.create_table( + "intra_decile_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("measure", sa.Text(), nullable=False), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("outcome_position", sa.Integer(), nullable=False), + sa.Column("decile", sa.SmallInteger(), nullable=True), + sa.Column("share", sa.Double(), nullable=False), + sa.CheckConstraint( + "measure IN ('income', 'wealth')", + name=op.f("ck_intra_decile_impacts_measure"), + ), + sa.CheckConstraint( + "decile IS NULL OR decile >= 1", name=op.f("ck_intra_decile_impacts_decile") + ), + sa.CheckConstraint( + "outcome_position >= 0", + name=op.f("ck_intra_decile_impacts_outcome_position"), + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_intra_decile_impacts_report_run", + ondelete="RESTRICT", + ), + sa.UniqueConstraint( + "report_run_id", + "measure", + "outcome", + "decile", + name=op.f("uq_intra_decile_impacts_report_run_id_measure_outcome_decile"), + postgresql_nulls_not_distinct=True, + ), + schema="simulation", + ) + op.create_table( + "labor_supply_decile_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("decile", sa.Integer(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("average_income_effect", sa.Double(), nullable=False), + sa.Column("average_substitution_effect", sa.Double(), nullable=False), + sa.Column("relative_income_effect", sa.Double(), nullable=False), + sa.Column("relative_substitution_effect", sa.Double(), nullable=False), + sa.CheckConstraint( + "position >= 0", name=op.f("ck_labor_supply_decile_impacts_position") + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_labor_supply_decile_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", "decile", name=op.f("pk_labor_supply_decile_impacts") + ), + sa.UniqueConstraint( + "report_run_id", + "position", + name=op.f("uq_labor_supply_decile_impacts_report_run_id_position"), + ), + schema="simulation", + ) + op.create_table( + "labor_supply_responses", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("substitution_lsr", sa.Double(), nullable=False), + sa.Column("income_lsr", sa.Double(), nullable=False), + sa.Column("relative_income_response", sa.Double(), nullable=False), + sa.Column("relative_substitution_response", sa.Double(), nullable=False), + sa.Column("total_change", sa.Double(), nullable=False), + sa.Column("revenue_change", sa.Double(), nullable=False), + sa.Column("baseline_hours", sa.Double(), nullable=False), + sa.Column("reform_hours", sa.Double(), nullable=False), + sa.Column("hours_change", sa.Double(), nullable=False), + sa.Column("hours_income_effect", sa.Double(), nullable=False), + sa.Column("hours_substitution_effect", sa.Double(), nullable=False), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_labor_supply_responses_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", name=op.f("pk_labor_supply_responses") + ), + schema="simulation", + ) + op.create_table( + "local_authority_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("local_authority_code", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("average_household_income_change", sa.Double(), nullable=False), + sa.Column("relative_household_income_change", sa.Double(), nullable=False), + sa.Column("population", sa.Double(), nullable=False), + sa.CheckConstraint( + "position >= 0", name=op.f("ck_local_authority_impacts_position") + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_local_authority_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", + "local_authority_code", + name=op.f("pk_local_authority_impacts"), + ), + sa.UniqueConstraint( + "report_run_id", + "position", + name=op.f("uq_local_authority_impacts_report_run_id_position"), + ), + schema="simulation", + ) + op.create_table( + "program_budget_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), + sa.Column("program", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("baseline", sa.Double(), nullable=False), + sa.Column("reform", sa.Double(), nullable=False), + sa.Column("difference", sa.Double(), nullable=False), + sa.CheckConstraint( + "position >= 0", name=op.f("ck_program_budget_impacts_position") + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_program_budget_impacts_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", "program", name=op.f("pk_program_budget_impacts") + ), + sa.UniqueConstraint( + "report_run_id", + "position", + name=op.f("uq_program_budget_impacts_report_run_id_position"), + ), + schema="simulation", + ) + op.add_column( + "report_runs", + sa.Column("result_sections", postgresql.ARRAY(sa.Text()), nullable=True), + schema="simulation", + ) + op.add_column( + "report_runs", + sa.Column( + "spm_provenance", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + schema="simulation", + ) + op.create_check_constraint( + op.f("ck_report_runs_spm_provenance"), + "report_runs", + "spm_provenance IS NULL OR jsonb_typeof(spm_provenance) = 'object'", + schema="simulation", + ) + + +def downgrade() -> None: + """Drop what ``upgrade`` created.""" + op.drop_constraint( + op.f("ck_report_runs_spm_provenance"), + "report_runs", + schema="simulation", + type_="check", + ) + op.drop_column("report_runs", "spm_provenance", schema="simulation") + op.drop_column("report_runs", "result_sections", schema="simulation") + op.drop_table("program_budget_impacts", schema="simulation") + op.drop_table("local_authority_impacts", schema="simulation") + op.drop_table("labor_supply_responses", schema="simulation") + op.drop_table("labor_supply_decile_impacts", schema="simulation") + op.drop_table("intra_decile_impacts", schema="simulation") + op.drop_table("decile_impacts", schema="simulation") + op.drop_table("constituency_impacts", schema="simulation") + op.drop_table("congressional_district_impacts", schema="simulation") + op.drop_table("budget_impacts", schema="simulation") + op.drop_table("poverty_rates", schema="simulation") + op.drop_table("inequality", schema="simulation") + op.drop_table("cliff_impacts", schema="simulation") diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index 31a2409dd..1dd314d3f 100644 --- a/projects/policyengine-simulation-entry/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -970,6 +970,7 @@ name = "policyengine-simulation-persistence" version = "0.1.0" source = { editable = "../../libs/policyengine-simulation-persistence" } dependencies = [ + { name = "policyengine-simulation-contract" }, { name = "psycopg", extra = ["binary"] }, { name = "sqlalchemy" }, ] @@ -978,6 +979,7 @@ dependencies = [ requires-dist = [ { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.17.1,<2" }, { name = "alembic", marker = "extra == 'test'", specifier = ">=1.17.1,<2" }, + { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, From 373e5e2b68e4d069de7d2ad167572355848d6ebe Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:14:46 +0400 Subject: [PATCH 083/124] Keep each UK area's disclosure tier and 90% intervals Since main took disclosure-controlled UK geography outputs, every published constituency and local-authority record carries disclosure_tier (reported or approximate) and the 90% interval bounds of its two income changes. The UK area tables had no columns for them, so a result passed the checks, lost the five fields on write and was read back with them null, which the contract keeps for results computed before disclosure control. constituency_impacts and local_authority_impacts now hold the five fields (nullable, with a check on the tier), and the writer and reader carry them. The UK national and Scotland samples now give every area a tier (some approximate), interval bounds and a map position; the local-authority sample keeps null fields, as an older result has. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 4 +- .../economy_results.py | 3 + .../result_tables.py | 34 +- .../uk_local_authority_2026.json | 9 +- .../uk_nation_scotland_2026.json | 565 +- .../economy_results/uk_national_2026.json | 8750 +++++++++++++---- .../tests/test_economy_results.py | 37 + .../versions/0004_economy_result_tables.py | 18 + 8 files changed, 7505 insertions(+), 1915 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 69bf2f3a6..0b82c8e72 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -82,7 +82,9 @@ the comparison step succeeded (`RunTransitions.complete_comparison`); result model. `read_economy_comparison` rebuilds the result exactly from the rows and from labels the region catalog supplies (`EconomyResultLabels`): the model and data versions of the run's catalog version, and constituency and -local-authority names and map positions by code. +local-authority names and map positions by code. Each UK area's disclosure +tier and the 90% interval bounds of its income changes are stored with its +figures (null for a result computed before disclosure control). ## Ownership rules diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index be92198e4..fb4eb23be 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -32,6 +32,7 @@ from sqlalchemy.dialects.postgresql import insert as postgres_insert from policyengine_simulation_persistence.result_tables import ( + UK_AREA_DISCLOSURE_COLUMNS, budget_impacts, cliff_impacts, congressional_district_impacts, @@ -328,6 +329,7 @@ def _geography_rows( record.relative_household_income_change ), "population": record.population, + **{name: getattr(record, name) for name in UK_AREA_DISCLOSURE_COLUMNS}, } for position, record in enumerate(records.root) ] @@ -604,6 +606,7 @@ def _labelled_geographies( "relative_household_income_change" ], "population": row["population"], + **{name: row[name] for name in UK_AREA_DISCLOSURE_COLUMNS}, } ) return records diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py index 51385752e..4a7cee744 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -8,15 +8,18 @@ their order in a ``position`` column, so a reader rebuilds the result exactly. Constituency and local-authority names and map positions are not stored: they -come from the region catalog by code. The model and data versions are pinned +come from the region catalog by code. Each area's disclosure tier and 90% +interval bounds are stored with its figures, null for a result computed before +disclosure control. The model and data versions are pinned by the run's catalog version. The SPM provenance and the list of optional sections a result holds are columns on the report run. """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, get_args +from policyengine_simulation_contract.macro_output import UKGeographyDisclosureTier from sqlalchemy import ( CheckConstraint, Column, @@ -44,6 +47,14 @@ "race": ("white", "black", "hispanic", "other"), } DECILE_MEASURES = ("income", "wealth") +UK_AREA_DISCLOSURE_TIERS: tuple[str, ...] = get_args(UKGeographyDisclosureTier) +UK_AREA_DISCLOSURE_COLUMNS = ( + "disclosure_tier", + "average_household_income_change_lower", + "average_household_income_change_upper", + "relative_household_income_change_lower", + "relative_household_income_change_upper", +) def _values(values: tuple[str, ...]) -> str: @@ -85,6 +96,23 @@ def _position() -> list[SchemaItem]: ] +def _uk_area_disclosure() -> list[SchemaItem]: + """Return a UK area's disclosure tier and its changes' 90% interval bounds. + + All are null for a result computed before disclosure control. + """ + tier_column, *bound_columns = UK_AREA_DISCLOSURE_COLUMNS + return [ + Column(tier_column, Text, nullable=True), + *(Column(name, Double, nullable=True) for name in bound_columns), + CheckConstraint( + f"{tier_column} IS NULL OR " + f"{tier_column} IN ({_values(UK_AREA_DISCLOSURE_TIERS)})", + name=tier_column, + ), + ] + + # --- Per simulation run ----------------------------------------------------- poverty_rates = Table( @@ -255,6 +283,7 @@ def _position() -> list[SchemaItem]: "relative_household_income_change", "population", ), + *_uk_area_disclosure(), PrimaryKeyConstraint("report_run_id", "constituency_code"), UniqueConstraint("report_run_id", "position"), ) @@ -270,6 +299,7 @@ def _position() -> list[SchemaItem]: "relative_household_income_change", "population", ), + *_uk_area_disclosure(), PrimaryKeyConstraint("report_run_id", "local_authority_code"), UniqueConstraint("report_run_id", "position"), ) diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json index 3d309bacd..832f978a9 100644 --- a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json @@ -300,10 +300,15 @@ "constituency_impact": null, "local_authority_impact": [ { + "disclosure_tier": null, + "average_household_income_change_lower": null, + "average_household_income_change_upper": null, + "relative_household_income_change_lower": null, + "relative_household_income_change_upper": null, "local_authority_code": "E06000000001", "local_authority_name": "Local Authority 1", - "x": null, - "y": null, + "x": 0, + "y": 0, "average_household_income_change": 23.761655080990707, "relative_household_income_change": 0.45328471316462604, "population": 103153.12548296218 diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json index f7212bfcd..97e364348 100644 --- a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json @@ -299,15 +299,25 @@ "labor_supply_response": null, "constituency_impact": [ { + "disclosure_tier": "reported", + "average_household_income_change_lower": -29.0, + "average_household_income_change_upper": -15.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000001", "constituency_name": "Constituency 1", - "x": null, - "y": null, + "x": 0, + "y": 0, "average_household_income_change": -22.0502560822613, "relative_household_income_change": 1e-12, "population": 79332.57320595343 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1030.0, + "average_household_income_change_upper": 1402.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000002", "constituency_name": "Constituency 2", "x": 1, @@ -317,6 +327,11 @@ "population": 85934.48703372813 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -438.0, + "average_household_income_change_upper": -318.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000003", "constituency_name": "Constituency 3", "x": 2, @@ -326,6 +341,11 @@ "population": 83465.39782866623 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2274.0, + "average_household_income_change_upper": -1675.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000004", "constituency_name": "Constituency 4", "x": 3, @@ -335,6 +355,11 @@ "population": 113477.96855441103 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -751.0, + "average_household_income_change_upper": -549.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000005", "constituency_name": "Constituency 5", "x": 4, @@ -344,6 +369,11 @@ "population": 62789.23357183145 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -449.0, + "average_household_income_change_upper": -326.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000006", "constituency_name": "Constituency 6", "x": 5, @@ -353,6 +383,11 @@ "population": 94065.14389717858 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -838.0, + "average_household_income_change_upper": -614.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000007", "constituency_name": "Constituency 7", "x": 6, @@ -362,15 +397,25 @@ "population": 128189.16865653818 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 387.0, + "average_household_income_change_upper": 532.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000008", "constituency_name": "Constituency 8", - "x": null, - "y": null, + "x": 7, + "y": 0, "average_household_income_change": 459.8825949063641, "relative_household_income_change": -0.0, "population": 63046.60303942481 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2035.0, + "average_household_income_change_upper": -1498.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000009", "constituency_name": "Constituency 9", "x": 8, @@ -380,6 +425,11 @@ "population": 103944.30710763739 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1909.0, + "average_household_income_change_upper": -1405.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000010", "constituency_name": "Constituency 10", "x": 9, @@ -389,6 +439,11 @@ "population": 92293.1508555326 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1807.0, + "average_household_income_change_upper": -1330.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000011", "constituency_name": "Constituency 11", "x": 10, @@ -398,6 +453,11 @@ "population": 89367.41666308911 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1775.0, + "average_household_income_change_upper": -1306.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000012", "constituency_name": "Constituency 12", "x": 11, @@ -407,6 +467,11 @@ "population": 53137.15027763359 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -61.0, + "average_household_income_change_upper": -39.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000013", "constituency_name": "Constituency 13", "x": 12, @@ -416,6 +481,11 @@ "population": 69494.2870251734 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1974.0, + "average_household_income_change_upper": -1453.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000014", "constituency_name": "Constituency 14", "x": 13, @@ -425,15 +495,25 @@ "population": 114944.10521441708 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 434.0, + "average_household_income_change_upper": 596.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000015", "constituency_name": "Constituency 15", - "x": null, - "y": null, + "x": 14, + "y": 0, "average_household_income_change": 515.040158455704, "relative_household_income_change": 1e-12, "population": 123680.0940363324 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 750.0, + "average_household_income_change_upper": 1023.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000016", "constituency_name": "Constituency 16", "x": 15, @@ -443,6 +523,11 @@ "population": 133563.797744863 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1567.0, + "average_household_income_change_upper": 2129.0, + "relative_household_income_change_lower": 0.8241, + "relative_household_income_change_upper": 1.1161, "constituency_code": "S14000000017", "constituency_name": "Constituency 17", "x": 16, @@ -452,6 +537,11 @@ "population": 96561.24036341548 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -233.0, + "average_household_income_change_upper": -166.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000018", "constituency_name": "Constituency 18", "x": 17, @@ -461,6 +551,11 @@ "population": 130680.98864876162 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1483.0, + "average_household_income_change_upper": -1090.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000019", "constituency_name": "Constituency 19", "x": 18, @@ -470,6 +565,11 @@ "population": 73823.59031650981 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1350.0, + "average_household_income_change_upper": -992.0, + "relative_household_income_change_lower": 0.7146, + "relative_household_income_change_upper": 0.9678, "constituency_code": "S14000000020", "constituency_name": "Constituency 20", "x": 19, @@ -479,6 +579,11 @@ "population": 95406.04234472507 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -833.0, + "average_household_income_change_upper": -609.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000021", "constituency_name": "Constituency 21", "x": 20, @@ -488,15 +593,25 @@ "population": 68215.09086082038 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1531.0, + "average_household_income_change_upper": -1125.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000022", "constituency_name": "Constituency 22", - "x": null, - "y": null, + "x": 21, + "y": 0, "average_household_income_change": -1327.8413593194002, "relative_household_income_change": 1e-12, "population": 139680.9189915626 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1547.0, + "average_household_income_change_upper": 2101.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000023", "constituency_name": "Constituency 23", "x": 22, @@ -506,6 +621,11 @@ "population": 135075.91381560973 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 494.0, + "average_household_income_change_upper": 677.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000024", "constituency_name": "Constituency 24", "x": 23, @@ -515,6 +635,11 @@ "population": 62805.9120249973 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -15.0, + "average_household_income_change_upper": -5.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000025", "constituency_name": "Constituency 25", "x": 24, @@ -524,6 +649,11 @@ "population": 93527.3783158783 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -298.0, + "average_household_income_change_upper": -214.0, + "relative_household_income_change_lower": 0.5463, + "relative_household_income_change_upper": 0.7402, "constituency_code": "S14000000026", "constituency_name": "Constituency 26", "x": 25, @@ -533,6 +663,11 @@ "population": 88101.31906153599 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1444.0, + "average_household_income_change_upper": 1962.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000027", "constituency_name": "Constituency 27", "x": 26, @@ -542,6 +677,11 @@ "population": 125414.43486372594 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1037.0, + "average_household_income_change_upper": 1412.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000028", "constituency_name": "Constituency 28", "x": 27, @@ -551,15 +691,25 @@ "population": 125936.64670777883 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -61.0, + "average_household_income_change_upper": -39.0, + "relative_household_income_change_lower": 0.4285, + "relative_household_income_change_upper": 0.5807, "constituency_code": "S14000000029", "constituency_name": "Constituency 29", - "x": null, - "y": null, + "x": 28, + "y": 0, "average_household_income_change": -50.36433936503304, "relative_household_income_change": 0.5045905570059873, "population": 110678.73316985903 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2059.0, + "average_household_income_change_upper": -1516.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000030", "constituency_name": "Constituency 30", "x": 29, @@ -569,6 +719,11 @@ "population": 137815.36506324858 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -823.0, + "average_household_income_change_upper": -602.0, + "relative_household_income_change_lower": 0.6915, + "relative_household_income_change_upper": 0.9366, "constituency_code": "S14000000031", "constituency_name": "Constituency 31", "x": 30, @@ -578,235 +733,365 @@ "population": 77978.11324744608 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1377.0, + "average_household_income_change_upper": 1871.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000032", "constituency_name": "Constituency 32", - "x": 0, - "y": 1, + "x": 31, + "y": 0, "average_household_income_change": 1623.627170035723, "relative_household_income_change": -0.0, "population": 106220.79881424447 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1015.0, + "average_household_income_change_upper": -744.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000033", "constituency_name": "Constituency 33", - "x": 1, - "y": 1, + "x": 32, + "y": 0, "average_household_income_change": -879.6243473802599, "relative_household_income_change": -0.0, "population": 113212.96419666243 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -877.0, + "average_household_income_change_upper": -642.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000034", "constituency_name": "Constituency 34", - "x": 2, - "y": 1, + "x": 33, + "y": 0, "average_household_income_change": -759.2250650364525, "relative_household_income_change": -0.0, "population": 75244.09885972543 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 275.0, + "average_household_income_change_upper": 381.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000035", "constituency_name": "Constituency 35", - "x": 3, - "y": 1, + "x": 34, + "y": 0, "average_household_income_change": 328.0589704575538, "relative_household_income_change": -0.0, "population": 100810.42044062808 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 952.0, + "average_household_income_change_upper": 1296.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000036", "constituency_name": "Constituency 36", - "x": null, - "y": null, + "x": 35, + "y": 0, "average_household_income_change": 1123.594288313716, "relative_household_income_change": 1e-12, "population": 124050.46952325002 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -242.0, + "average_household_income_change_upper": -173.0, + "relative_household_income_change_lower": 0.4808, + "relative_household_income_change_upper": 0.6515, "constituency_code": "S14000000037", "constituency_name": "Constituency 37", - "x": 5, - "y": 1, + "x": 36, + "y": 0, "average_household_income_change": -207.40824634245314, "relative_household_income_change": 0.566121478909492, "population": 86172.31704600062 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 380.0, + "average_household_income_change_upper": 522.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000038", "constituency_name": "Constituency 38", - "x": 6, - "y": 1, + "x": 37, + "y": 0, "average_household_income_change": 450.9534054524922, "relative_household_income_change": 1e-12, "population": 71202.13598416587 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -584.0, + "average_household_income_change_upper": -426.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000039", "constituency_name": "Constituency 39", - "x": 7, - "y": 1, + "x": 38, + "y": 0, "average_household_income_change": -505.1320248700919, "relative_household_income_change": -0.0, "population": 78380.02565944156 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 310.0, + "average_household_income_change_upper": 428.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000040", "constituency_name": "Constituency 40", - "x": 8, - "y": 1, + "x": 39, + "y": 0, "average_household_income_change": 369.07046484015103, "relative_household_income_change": 0.0, "population": 96559.91677812286 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1464.0, + "average_household_income_change_upper": 1988.0, + "relative_household_income_change_lower": 0.5417, + "relative_household_income_change_upper": 0.7339, "constituency_code": "S14000000041", "constituency_name": "Constituency 41", - "x": 9, + "x": 0, "y": 1, "average_household_income_change": 1725.916837222794, "relative_household_income_change": 0.6377702114609652, "population": 54537.937931824395 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1127.0, + "average_household_income_change_upper": 1533.0, + "relative_household_income_change_lower": 0.4754, + "relative_household_income_change_upper": 0.6442, "constituency_code": "S14000000042", "constituency_name": "Constituency 42", - "x": 10, + "x": 1, "y": 1, "average_household_income_change": 1330.2517858463264, "relative_household_income_change": 0.5598026330340823, "population": 52934.12546599741 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1498.0, + "average_household_income_change_upper": 2035.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000043", "constituency_name": "Constituency 43", - "x": null, - "y": null, + "x": 2, + "y": 1, "average_household_income_change": 1766.9186377099031, "relative_household_income_change": 1e-12, "population": 67014.41931048906 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -663.0, + "average_household_income_change_upper": -484.0, + "relative_household_income_change_lower": 0.506, + "relative_household_income_change_upper": 0.6857, "constituency_code": "S14000000044", "constituency_name": "Constituency 44", - "x": 12, + "x": 3, "y": 1, "average_household_income_change": -573.5210003270792, "relative_household_income_change": 0.5958518145639365, "population": 143726.2105377167 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1317.0, + "average_household_income_change_upper": -967.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000045", "constituency_name": "Constituency 45", - "x": 13, + "x": 4, "y": 1, "average_household_income_change": -1142.3319096547716, "relative_household_income_change": 1e-12, "population": 127568.64904169193 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2289.0, + "average_household_income_change_upper": -1685.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000046", "constituency_name": "Constituency 46", - "x": 14, + "x": 5, "y": 1, "average_household_income_change": -1987.0210639986383, "relative_household_income_change": -0.0, "population": 79684.65150836893 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1038.0, + "average_household_income_change_upper": -761.0, + "relative_household_income_change_lower": 0.2846, + "relative_household_income_change_upper": 0.3861, "constituency_code": "S14000000047", "constituency_name": "Constituency 47", - "x": 15, + "x": 6, "y": 1, "average_household_income_change": -899.9641960071924, "relative_household_income_change": 0.33535050184635984, "population": 110975.7867195766 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 34.0, + "average_household_income_change_upper": 54.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000048", "constituency_name": "Constituency 48", - "x": 16, + "x": 7, "y": 1, "average_household_income_change": 43.803712052223446, "relative_household_income_change": -0.0, "population": 55638.22656504041 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2140.0, + "average_household_income_change_upper": -1576.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000049", "constituency_name": "Constituency 49", - "x": 17, + "x": 8, "y": 1, "average_household_income_change": -1858.2148152893594, "relative_household_income_change": -0.0, "population": 61494.55783779679 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1521.0, + "average_household_income_change_upper": 2066.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000050", "constituency_name": "Constituency 50", - "x": null, - "y": null, + "x": 9, + "y": 1, "average_household_income_change": 1793.552834263271, "relative_household_income_change": 1e-12, "population": 71150.5956498904 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 332.0, + "average_household_income_change_upper": 458.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000051", "constituency_name": "Constituency 51", - "x": 19, + "x": 10, "y": 1, "average_household_income_change": 395.1686018800302, "relative_household_income_change": -3.5e-9, "population": 57577.16533695534 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1470.0, + "average_household_income_change_upper": 1998.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000052", "constituency_name": "Constituency 52", - "x": 20, + "x": 11, "y": 1, "average_household_income_change": 1734.0747439322786, "relative_household_income_change": 1e-12, "population": 65718.41606608071 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1178.0, + "average_household_income_change_upper": -864.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000053", "constituency_name": "Constituency 53", - "x": 21, + "x": 12, "y": 1, "average_household_income_change": -1021.0283577089085, "relative_household_income_change": 1e-12, "population": 52738.38868454951 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1013.0, + "average_household_income_change_upper": 1378.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000054", "constituency_name": "Constituency 54", - "x": 22, + "x": 13, "y": 1, "average_household_income_change": 1195.60033690023, "relative_household_income_change": 0.0, "population": 58687.69580485995 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1066.0, + "average_household_income_change_upper": 1450.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "S14000000055", "constituency_name": "Constituency 55", - "x": 23, + "x": 14, "y": 1, "average_household_income_change": 1258.2528562824318, "relative_household_income_change": 1e-12, "population": 52638.640102357116 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1373.0, + "average_household_income_change_upper": -1009.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "S14000000056", "constituency_name": "Constituency 56", - "x": 24, + "x": 15, "y": 1, "average_household_income_change": -1190.9949809860163, "relative_household_income_change": -3.5e-9, "population": 95505.81052752634 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1821.0, + "average_household_income_change_upper": -1340.0, + "relative_household_income_change_lower": 0.6979, + "relative_household_income_change_upper": 0.9453, "constituency_code": "S14000000057", "constituency_name": "Constituency 57", - "x": null, - "y": null, + "x": 16, + "y": 1, "average_household_income_change": -1580.6126310753368, "relative_household_income_change": 0.8216310930495441, "population": 64646.038428561056 @@ -814,15 +1099,25 @@ ], "local_authority_impact": [ { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1452.0, + "average_household_income_change_upper": 1972.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000001", "local_authority_name": "Local Authority 1", - "x": null, - "y": null, + "x": 0, + "y": 0, "average_household_income_change": 1711.9662635106972, "relative_household_income_change": -0.0, "population": 139245.28916534188 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -878.0, + "average_household_income_change_upper": -643.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000002", "local_authority_name": "Local Authority 2", "x": 1, @@ -832,6 +1127,11 @@ "population": 100805.127015022 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1560.0, + "average_household_income_change_upper": -1147.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000003", "local_authority_name": "Local Authority 3", "x": 2, @@ -841,6 +1141,11 @@ "population": 136496.49703708116 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2164.0, + "average_household_income_change_upper": -1594.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000004", "local_authority_name": "Local Authority 4", "x": 3, @@ -850,6 +1155,11 @@ "population": 127529.863915214 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -727.0, + "average_household_income_change_upper": -531.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000005", "local_authority_name": "Local Authority 5", "x": 4, @@ -859,6 +1169,11 @@ "population": 119156.76386104178 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 740.0, + "average_household_income_change_upper": 1009.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000006", "local_authority_name": "Local Authority 6", "x": 5, @@ -868,6 +1183,11 @@ "population": 106277.9233003826 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 394.0, + "average_household_income_change_upper": 542.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000007", "local_authority_name": "Local Authority 7", "x": 6, @@ -877,15 +1197,25 @@ "population": 139741.54468077584 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -637.0, + "average_household_income_change_upper": -465.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000008", "local_authority_name": "Local Authority 8", - "x": null, - "y": null, + "x": 7, + "y": 0, "average_household_income_change": -550.7700901310177, "relative_household_income_change": 0.0, "population": 80739.55317161283 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -967.0, + "average_household_income_change_upper": -709.0, + "relative_household_income_change_lower": 0.3623, + "relative_household_income_change_upper": 0.4912, "local_authority_code": "S12000000009", "local_authority_name": "Local Authority 9", "x": 8, @@ -895,6 +1225,11 @@ "population": 77962.94927239053 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1508.0, + "average_household_income_change_upper": -1109.0, + "relative_household_income_change_lower": 0.6215, + "relative_household_income_change_upper": 0.8419, "local_authority_code": "S12000000010", "local_authority_name": "Local Authority 10", "x": 9, @@ -904,6 +1239,11 @@ "population": 106985.32281751494 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1384.0, + "average_household_income_change_upper": -1017.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000011", "local_authority_name": "Local Authority 11", "x": 10, @@ -913,6 +1253,11 @@ "population": 60169.16904360067 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -623.0, + "average_household_income_change_upper": -455.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000012", "local_authority_name": "Local Authority 12", "x": 11, @@ -922,6 +1267,11 @@ "population": 134657.14469719745 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -603.0, + "average_household_income_change_upper": -440.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000013", "local_authority_name": "Local Authority 13", "x": 12, @@ -931,6 +1281,11 @@ "population": 84326.3910283849 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -468.0, + "average_household_income_change_upper": -340.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000014", "local_authority_name": "Local Authority 14", "x": 13, @@ -940,15 +1295,25 @@ "population": 90121.64285325728 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2295.0, + "average_household_income_change_upper": -1690.0, + "relative_household_income_change_lower": 0.4578, + "relative_household_income_change_upper": 0.6204, "local_authority_code": "S12000000015", "local_authority_name": "Local Authority 15", - "x": null, - "y": null, + "x": 14, + "y": 0, "average_household_income_change": -1992.3987578717731, "relative_household_income_change": 0.5390980006995861, "population": 57146.25648990415 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1622.0, + "average_household_income_change_upper": 2203.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000016", "local_authority_name": "Local Authority 16", "x": 15, @@ -958,6 +1323,11 @@ "population": 61630.540132526454 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 572.0, + "average_household_income_change_upper": 782.0, + "relative_household_income_change_lower": 0.1885, + "relative_household_income_change_upper": 0.2561, "local_authority_code": "S12000000017", "local_authority_name": "Local Authority 17", "x": 16, @@ -967,6 +1337,11 @@ "population": 83264.51133432586 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 622.0, + "average_household_income_change_upper": 850.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000018", "local_authority_name": "Local Authority 18", "x": 17, @@ -976,6 +1351,11 @@ "population": 60421.1365110476 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1030.0, + "average_household_income_change_upper": -755.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000019", "local_authority_name": "Local Authority 19", "x": 18, @@ -985,6 +1365,11 @@ "population": 149136.34982537729 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1588.0, + "average_household_income_change_upper": 2156.0, + "relative_household_income_change_lower": 0.7913, + "relative_household_income_change_upper": 1.0717, "local_authority_code": "S12000000020", "local_authority_name": "Local Authority 20", "x": 19, @@ -994,6 +1379,11 @@ "population": 82551.14096226152 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2045.0, + "average_household_income_change_upper": -1506.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000021", "local_authority_name": "Local Authority 21", "x": 20, @@ -1003,15 +1393,25 @@ "population": 142543.40081999943 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1422.0, + "average_household_income_change_upper": 1932.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000022", "local_authority_name": "Local Authority 22", - "x": null, - "y": null, + "x": 21, + "y": 0, "average_household_income_change": 1677.2264729092446, "relative_household_income_change": -3.5e-9, "population": 119213.98806155729 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 212.0, + "average_household_income_change_upper": 295.0, + "relative_household_income_change_lower": 0.1427, + "relative_household_income_change_upper": 0.1942, "local_authority_code": "S12000000023", "local_authority_name": "Local Authority 23", "x": 22, @@ -1021,6 +1421,11 @@ "population": 131349.02471522414 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2133.0, + "average_household_income_change_upper": -1570.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000024", "local_authority_name": "Local Authority 24", "x": 23, @@ -1030,6 +1435,11 @@ "population": 98606.58597465903 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1670.0, + "average_household_income_change_upper": -1228.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000025", "local_authority_name": "Local Authority 25", "x": 24, @@ -1039,6 +1449,11 @@ "population": 57564.83061204634 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1007.0, + "average_household_income_change_upper": -738.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000026", "local_authority_name": "Local Authority 26", "x": 25, @@ -1048,6 +1463,11 @@ "population": 125783.43975305068 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1512.0, + "average_household_income_change_upper": -1112.0, + "relative_household_income_change_lower": 0.8421, + "relative_household_income_change_upper": 1.1404, "local_authority_code": "S12000000027", "local_authority_name": "Local Authority 27", "x": 26, @@ -1057,6 +1477,11 @@ "population": 81237.5561149096 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 486.0, + "average_household_income_change_upper": 666.0, + "relative_household_income_change_lower": 0.5978, + "relative_household_income_change_upper": 0.8099, "local_authority_code": "S12000000028", "local_authority_name": "Local Authority 28", "x": 27, @@ -1066,15 +1491,25 @@ "population": 141107.730757162 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 812.0, + "average_household_income_change_upper": 1106.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000029", "local_authority_name": "Local Authority 29", - "x": null, - "y": null, + "x": 28, + "y": 0, "average_household_income_change": 958.8442127965027, "relative_household_income_change": 1e-12, "population": 102312.94874371101 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1549.0, + "average_household_income_change_upper": -1139.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000030", "local_authority_name": "Local Authority 30", "x": 29, @@ -1084,6 +1519,11 @@ "population": 103091.15965651689 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -34.0, + "average_household_income_change_upper": -19.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "S12000000031", "local_authority_name": "Local Authority 31", "x": 30, @@ -1093,10 +1533,15 @@ "population": 139125.5993482639 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1591.0, + "average_household_income_change_upper": 2160.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "S12000000032", "local_authority_name": "Local Authority 32", - "x": 0, - "y": 1, + "x": 31, + "y": 0, "average_household_income_change": 1875.5073255172429, "relative_household_income_change": 1e-12, "population": 52821.895044486846 diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json index 726c2d974..414ea324a 100644 --- a/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json @@ -369,15 +369,25 @@ }, "constituency_impact": [ { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1623.0, + "average_household_income_change_upper": 2204.0, + "relative_household_income_change_lower": 0.0459, + "relative_household_income_change_upper": 0.0632, "constituency_code": "E14000000001", "constituency_name": "Constituency 1", - "x": null, - "y": null, + "x": 0, + "y": 0, "average_household_income_change": 1913.4194334839867, "relative_household_income_change": 0.05452265501045739, "population": 98386.17433244424 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1498.0, + "average_household_income_change_upper": 2035.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000002", "constituency_name": "Constituency 2", "x": 1, @@ -387,6 +397,11 @@ "population": 65914.779888391 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1679.0, + "average_household_income_change_upper": 2279.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000003", "constituency_name": "Constituency 3", "x": 2, @@ -396,6 +411,11 @@ "population": 146525.5307749652 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -281.0, + "average_household_income_change_upper": -202.0, + "relative_household_income_change_lower": 0.0005, + "relative_household_income_change_upper": 0.0018, "constituency_code": "E14000000004", "constituency_name": "Constituency 4", "x": 3, @@ -405,6 +425,11 @@ "population": 88718.4004768281 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2252.0, + "average_household_income_change_upper": -1659.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000005", "constituency_name": "Constituency 5", "x": 4, @@ -414,6 +439,11 @@ "population": 98185.74579851818 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 422.0, + "average_household_income_change_upper": 579.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000006", "constituency_name": "Constituency 6", "x": 5, @@ -423,6 +453,11 @@ "population": 91600.07992596942 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1212.0, + "average_household_income_change_upper": 1648.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000007", "constituency_name": "Constituency 7", "x": 6, @@ -432,15 +467,25 @@ "population": 144767.61377682418 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 254.0, + "average_household_income_change_upper": 352.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000008", "constituency_name": "Constituency 8", - "x": null, - "y": null, + "x": 7, + "y": 0, "average_household_income_change": 303.4651099150256, "relative_household_income_change": 1e-12, "population": 104370.43989569198 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1649.0, + "average_household_income_change_upper": -1213.0, + "relative_household_income_change_lower": 0.4419, + "relative_household_income_change_upper": 0.599, "constituency_code": "E14000000009", "constituency_name": "Constituency 9", "x": 8, @@ -450,6 +495,11 @@ "population": 120355.88560972406 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1015.0, + "average_household_income_change_upper": -744.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000010", "constituency_name": "Constituency 10", "x": 9, @@ -459,6 +509,11 @@ "population": 131238.10258698944 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 529.0, + "average_household_income_change_upper": 724.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000011", "constituency_name": "Constituency 11", "x": 10, @@ -468,6 +523,11 @@ "population": 70186.41755304152 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1364.0, + "average_household_income_change_upper": -1002.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000012", "constituency_name": "Constituency 12", "x": 11, @@ -477,6 +537,11 @@ "population": 95668.57228708114 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1514.0, + "average_household_income_change_upper": -1113.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000013", "constituency_name": "Constituency 13", "x": 12, @@ -486,6 +551,11 @@ "population": 58717.759794404556 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 441.0, + "average_household_income_change_upper": 605.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000014", "constituency_name": "Constituency 14", "x": 13, @@ -495,15 +565,25 @@ "population": 105485.06719596757 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -329.0, + "average_household_income_change_upper": -237.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000015", "constituency_name": "Constituency 15", - "x": null, - "y": null, + "x": 14, + "y": 0, "average_household_income_change": -283.01292441674696, "relative_household_income_change": -0.0, "population": 64972.126858879885 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1560.0, + "average_household_income_change_upper": 2118.0, + "relative_household_income_change_lower": 0.7247, + "relative_household_income_change_upper": 0.9815, "constituency_code": "E14000000016", "constituency_name": "Constituency 16", "x": 15, @@ -513,6 +593,11 @@ "population": 95302.11433575382 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1533.0, + "average_household_income_change_upper": 2082.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000017", "constituency_name": "Constituency 17", "x": 16, @@ -522,6 +607,11 @@ "population": 102662.03335442903 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1104.0, + "average_household_income_change_upper": 1501.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000018", "constituency_name": "Constituency 18", "x": 17, @@ -531,6 +621,11 @@ "population": 102906.71208383882 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 579.0, + "average_household_income_change_upper": 791.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000019", "constituency_name": "Constituency 19", "x": 18, @@ -540,6 +635,11 @@ "population": 54222.044888242985 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 412.0, + "average_household_income_change_upper": 565.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000020", "constituency_name": "Constituency 20", "x": 19, @@ -549,6 +649,11 @@ "population": 57009.64734391624 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2104.0, + "average_household_income_change_upper": -1549.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000021", "constituency_name": "Constituency 21", "x": 20, @@ -558,15 +663,25 @@ "population": 60607.39438166653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 797.0, + "average_household_income_change_upper": 1086.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000022", "constituency_name": "Constituency 22", - "x": null, - "y": null, + "x": 21, + "y": 0, "average_household_income_change": 941.3762870336742, "relative_household_income_change": 0.0, "population": 65082.17476365694 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -195.0, + "average_household_income_change_upper": -138.0, + "relative_household_income_change_lower": 0.0388, + "relative_household_income_change_upper": 0.0536, "constituency_code": "E14000000023", "constituency_name": "Constituency 23", "x": 22, @@ -576,6 +691,11 @@ "population": 65296.52386416295 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1611.0, + "average_household_income_change_upper": -1185.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000024", "constituency_name": "Constituency 24", "x": 23, @@ -585,6 +705,11 @@ "population": 82358.07690579105 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1345.0, + "average_household_income_change_upper": -988.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000025", "constituency_name": "Constituency 25", "x": 24, @@ -594,6 +719,11 @@ "population": 58056.9265454936 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 935.0, + "average_household_income_change_upper": 1273.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000026", "constituency_name": "Constituency 26", "x": 25, @@ -603,6 +733,11 @@ "population": 138706.63023281284 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1178.0, + "average_household_income_change_upper": 1602.0, + "relative_household_income_change_lower": 0.1629, + "relative_household_income_change_upper": 0.2214, "constituency_code": "E14000000027", "constituency_name": "Constituency 27", "x": 26, @@ -612,6 +747,11 @@ "population": 82600.12292622661 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -636.0, + "average_household_income_change_upper": -464.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000028", "constituency_name": "Constituency 28", "x": 27, @@ -621,15 +761,25 @@ "population": 66882.2129669484 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -436.0, + "average_household_income_change_upper": -316.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000029", "constituency_name": "Constituency 29", - "x": null, - "y": null, + "x": 28, + "y": 0, "average_household_income_change": -376.0757965327994, "relative_household_income_change": -0.0, "population": 131755.4233245176 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2083.0, + "average_household_income_change_upper": -1533.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000030", "constituency_name": "Constituency 30", "x": 29, @@ -639,6 +789,11 @@ "population": 76098.68374829671 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1001.0, + "average_household_income_change_upper": 1363.0, + "relative_household_income_change_lower": 0.581, + "relative_household_income_change_upper": 0.7872, "constituency_code": "E14000000031", "constituency_name": "Constituency 31", "x": 30, @@ -648,5572 +803,8667 @@ "population": 97597.70307584907 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2279.0, + "average_household_income_change_upper": -1678.0, + "relative_household_income_change_lower": 0.824, + "relative_household_income_change_upper": 1.1158, "constituency_code": "E14000000032", "constituency_name": "Constituency 32", - "x": 0, - "y": 1, + "x": 31, + "y": 0, "average_household_income_change": -1978.4430390331474, "relative_household_income_change": 0.9698872181871829, "population": 119385.14894333272 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1070.0, + "average_household_income_change_upper": -785.0, + "relative_household_income_change_lower": 0.5568, + "relative_household_income_change_upper": 0.7544, "constituency_code": "E14000000033", "constituency_name": "Constituency 33", - "x": 1, - "y": 1, + "x": 32, + "y": 0, "average_household_income_change": -927.783221433379, "relative_household_income_change": 0.6556433745742408, "population": 56063.824089337824 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 650.0, + "average_household_income_change_upper": 887.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000034", "constituency_name": "Constituency 34", - "x": 2, - "y": 1, + "x": 33, + "y": 0, "average_household_income_change": 768.3322762327842, "relative_household_income_change": -0.0, "population": 146677.93429020338 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1992.0, + "average_household_income_change_upper": -1467.0, + "relative_household_income_change_lower": 0.6179, + "relative_household_income_change_upper": 0.837, "constituency_code": "E14000000035", "constituency_name": "Constituency 35", - "x": 3, - "y": 1, + "x": 34, + "y": 0, "average_household_income_change": -1729.540058696729, "relative_household_income_change": 0.7274163682705892, "population": 141793.29131328876 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -977.0, + "average_household_income_change_upper": -716.0, + "relative_household_income_change_lower": 0.7206, + "relative_household_income_change_upper": 0.976, "constituency_code": "E14000000036", "constituency_name": "Constituency 36", - "x": null, - "y": null, + "x": 35, + "y": 0, "average_household_income_change": -846.208132023205, "relative_household_income_change": 0.8483280186604456, "population": 67143.97059156044 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2231.0, + "average_household_income_change_upper": -1643.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000037", "constituency_name": "Constituency 37", - "x": 5, - "y": 1, + "x": 36, + "y": 0, "average_household_income_change": -1936.6401638583116, "relative_household_income_change": -0.0, "population": 61672.55464881558 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -727.0, + "average_household_income_change_upper": -531.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000038", "constituency_name": "Constituency 38", - "x": 6, - "y": 1, + "x": 37, + "y": 0, "average_household_income_change": -628.8145345899005, "relative_household_income_change": 1e-12, "population": 132985.11142986448 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2048.0, + "average_household_income_change_upper": -1507.0, + "relative_household_income_change_lower": 0.448, + "relative_household_income_change_upper": 0.6072, "constituency_code": "E14000000039", "constituency_name": "Constituency 39", - "x": 7, - "y": 1, + "x": 38, + "y": 0, "average_household_income_change": -1777.3930705516138, "relative_household_income_change": 0.5276137376872608, "population": 127787.70788799871 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1084.0, + "average_household_income_change_upper": -795.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000040", "constituency_name": "Constituency 40", - "x": 8, - "y": 1, + "x": 39, + "y": 0, "average_household_income_change": -939.8753120050504, "relative_household_income_change": 1e-12, "population": 102035.60610842177 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1694.0, + "average_household_income_change_upper": 2300.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000041", "constituency_name": "Constituency 41", - "x": 9, + "x": 0, "y": 1, "average_household_income_change": 1996.581447976601, "relative_household_income_change": -3.5e-9, "population": 62516.54815850142 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2189.0, + "average_household_income_change_upper": -1612.0, + "relative_household_income_change_lower": 0.5008, + "relative_household_income_change_upper": 0.6787, "constituency_code": "E14000000042", "constituency_name": "Constituency 42", - "x": 10, + "x": 1, "y": 1, "average_household_income_change": -1900.8595839448872, "relative_household_income_change": 0.5897475984561965, "population": 68761.98222160083 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -755.0, + "average_household_income_change_upper": -552.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000043", "constituency_name": "Constituency 43", - "x": null, - "y": null, + "x": 2, + "y": 1, "average_household_income_change": -653.4834431595642, "relative_household_income_change": -0.0, "population": 51348.43227638599 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1738.0, + "average_household_income_change_upper": -1279.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000044", "constituency_name": "Constituency 44", - "x": 12, + "x": 3, "y": 1, "average_household_income_change": -1508.6955230422716, "relative_household_income_change": -3.5e-9, "population": 120576.53453498721 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1569.0, + "average_household_income_change_upper": -1154.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000045", "constituency_name": "Constituency 45", - "x": 13, + "x": 4, "y": 1, "average_household_income_change": -1361.5842774754205, "relative_household_income_change": -0.0, "population": 57742.78406589189 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -410.0, + "average_household_income_change_upper": -297.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000046", "constituency_name": "Constituency 46", - "x": 14, + "x": 5, "y": 1, "average_household_income_change": -353.29379038553776, "relative_household_income_change": -3.5e-9, "population": 126551.15106107993 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1927.0, + "average_household_income_change_upper": -1418.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000047", "constituency_name": "Constituency 47", - "x": 15, + "x": 6, "y": 1, "average_household_income_change": -1672.7900598157826, "relative_household_income_change": -3.5e-9, "population": 144378.29471218237 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 642.0, + "average_household_income_change_upper": 876.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000048", "constituency_name": "Constituency 48", - "x": 16, + "x": 7, "y": 1, "average_household_income_change": 758.8777810416283, "relative_household_income_change": -0.0, "population": 115096.92998669462 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1796.0, + "average_household_income_change_upper": -1322.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000049", "constituency_name": "Constituency 49", - "x": 17, + "x": 8, "y": 1, "average_household_income_change": -1559.0227122087886, "relative_household_income_change": 1e-12, "population": 106776.20642347747 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 668.0, + "average_household_income_change_upper": 912.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000050", "constituency_name": "Constituency 50", - "x": null, - "y": null, + "x": 9, + "y": 1, "average_household_income_change": 789.7011489873229, "relative_household_income_change": 0.0, "population": 127076.9781852224 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 131.0, + "average_household_income_change_upper": 186.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000051", "constituency_name": "Constituency 51", - "x": 19, + "x": 10, "y": 1, "average_household_income_change": 158.31787373201277, "relative_household_income_change": -3.5e-9, "population": 77473.57871872757 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 460.0, + "average_household_income_change_upper": 631.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000052", "constituency_name": "Constituency 52", - "x": 20, + "x": 11, "y": 1, "average_household_income_change": 545.7397560998354, "relative_household_income_change": 1e-12, "population": 123149.70477872998 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2292.0, + "average_household_income_change_upper": -1688.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000053", "constituency_name": "Constituency 53", - "x": 21, + "x": 12, "y": 1, "average_household_income_change": -1989.6254923025228, "relative_household_income_change": -3.5e-9, "population": 138656.01900310494 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1509.0, + "average_household_income_change_upper": -1109.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000054", "constituency_name": "Constituency 54", - "x": 22, + "x": 13, "y": 1, "average_household_income_change": -1309.1057736433424, "relative_household_income_change": -3.5e-9, "population": 105353.90714704938 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1050.0, + "average_household_income_change_upper": -770.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000055", "constituency_name": "Constituency 55", - "x": 23, + "x": 14, "y": 1, "average_household_income_change": -910.2455237869887, "relative_household_income_change": 1e-12, "population": 92014.34882548383 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2181.0, + "average_household_income_change_upper": -1606.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000056", "constituency_name": "Constituency 56", - "x": 24, + "x": 15, "y": 1, "average_household_income_change": -1893.3912344738899, "relative_household_income_change": 1e-12, "population": 62745.988954386 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -272.0, + "average_household_income_change_upper": -195.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000057", "constituency_name": "Constituency 57", - "x": null, - "y": null, + "x": 16, + "y": 1, "average_household_income_change": -233.6121744507622, "relative_household_income_change": 0.0, "population": 80531.25807633463 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1461.0, + "average_household_income_change_upper": 1985.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000058", "constituency_name": "Constituency 58", - "x": 26, + "x": 17, "y": 1, "average_household_income_change": 1723.235202484926, "relative_household_income_change": 0.0, "population": 62353.128351459265 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1142.0, + "average_household_income_change_upper": -838.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000059", "constituency_name": "Constituency 59", - "x": 27, + "x": 18, "y": 1, "average_household_income_change": -989.7236409560759, "relative_household_income_change": 0.0, "population": 82844.38317215677 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1573.0, + "average_household_income_change_upper": 2136.0, + "relative_household_income_change_lower": 0.8021, + "relative_household_income_change_upper": 1.0863, "constituency_code": "E14000000060", "constituency_name": "Constituency 60", - "x": 28, + "x": 19, "y": 1, "average_household_income_change": 1854.5416655237204, "relative_household_income_change": 0.9442223354304049, "population": 118156.82723049479 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1436.0, + "average_household_income_change_upper": -1055.0, + "relative_household_income_change_lower": 0.093, + "relative_household_income_change_upper": 0.1269, "constituency_code": "E14000000061", "constituency_name": "Constituency 61", - "x": 29, + "x": 20, "y": 1, "average_household_income_change": -1245.781306574102, "relative_household_income_change": 0.10992560517344074, "population": 121532.76585539519 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 377.0, + "average_household_income_change_upper": 518.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000062", "constituency_name": "Constituency 62", - "x": 30, + "x": 21, "y": 1, "average_household_income_change": 447.36217162511946, "relative_household_income_change": -0.0, "population": 90921.07222144597 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1678.0, + "average_household_income_change_upper": 2278.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000063", "constituency_name": "Constituency 63", - "x": 0, - "y": 2, + "x": 22, + "y": 1, "average_household_income_change": 1977.7483196114183, "relative_household_income_change": -0.0, "population": 145071.50512103183 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 708.0, + "average_household_income_change_upper": 966.0, + "relative_household_income_change_lower": 0.3249, + "relative_household_income_change_upper": 0.4406, "constituency_code": "E14000000064", "constituency_name": "Constituency 64", - "x": null, - "y": null, + "x": 23, + "y": 1, "average_household_income_change": 836.8416626768695, "relative_household_income_change": 0.3827069272720205, "population": 80491.97355110932 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -747.0, + "average_household_income_change_upper": -546.0, + "relative_household_income_change_lower": 0.5488, + "relative_household_income_change_upper": 0.7435, "constituency_code": "E14000000065", "constituency_name": "Constituency 65", - "x": 2, - "y": 2, + "x": 24, + "y": 1, "average_household_income_change": -646.4321678071556, "relative_household_income_change": 0.6461356256019504, "population": 82414.78499033627 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -314.0, + "average_household_income_change_upper": -226.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000066", "constituency_name": "Constituency 66", - "x": 3, - "y": 2, + "x": 25, + "y": 1, "average_household_income_change": -269.67895991541104, "relative_household_income_change": 0.0, "population": 107324.65179222659 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -885.0, + "average_household_income_change_upper": -648.0, + "relative_household_income_change_lower": 0.4548, + "relative_household_income_change_upper": 0.6163, "constituency_code": "E14000000067", "constituency_name": "Constituency 67", - "x": 4, - "y": 2, + "x": 26, + "y": 1, "average_household_income_change": -766.5123752502452, "relative_household_income_change": 0.5355509950976918, "population": 121437.09706568257 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 964.0, + "average_household_income_change_upper": 1312.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000068", "constituency_name": "Constituency 68", - "x": 5, - "y": 2, + "x": 27, + "y": 1, "average_household_income_change": 1137.686580720843, "relative_household_income_change": 1e-12, "population": 105524.95460769659 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1966.0, + "average_household_income_change_upper": -1447.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000069", "constituency_name": "Constituency 69", - "x": 6, - "y": 2, + "x": 28, + "y": 1, "average_household_income_change": -1706.7509137766676, "relative_household_income_change": 1e-12, "population": 89192.43391252482 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 243.0, + "average_household_income_change_upper": 337.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000070", "constituency_name": "Constituency 70", - "x": 7, - "y": 2, + "x": 29, + "y": 1, "average_household_income_change": 290.0010881146389, "relative_household_income_change": 0.0, "population": 105626.728450015 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1200.0, + "average_household_income_change_upper": -881.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000071", "constituency_name": "Constituency 71", - "x": null, - "y": null, + "x": 30, + "y": 1, "average_household_income_change": -1040.696959610678, "relative_household_income_change": -0.0, "population": 113268.62178290357 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -735.0, + "average_household_income_change_upper": -537.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000072", "constituency_name": "Constituency 72", - "x": 9, - "y": 2, + "x": 31, + "y": 1, "average_household_income_change": -635.9903698714575, "relative_household_income_change": 0.0, "population": 56501.415493660155 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2136.0, + "average_household_income_change_upper": -1573.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000073", "constituency_name": "Constituency 73", - "x": 10, - "y": 2, + "x": 32, + "y": 1, "average_household_income_change": -1854.3994611686637, "relative_household_income_change": -3.5e-9, "population": 102254.43848842036 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -108.0, + "average_household_income_change_upper": -74.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000074", "constituency_name": "Constituency 74", - "x": 11, - "y": 2, + "x": 33, + "y": 1, "average_household_income_change": -90.81831049588996, "relative_household_income_change": -0.0, "population": 80954.3654644868 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -495.0, + "average_household_income_change_upper": -360.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000075", "constituency_name": "Constituency 75", - "x": 12, - "y": 2, + "x": 34, + "y": 1, "average_household_income_change": -427.27767032936634, "relative_household_income_change": -0.0, "population": 108071.93041754297 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -150.0, + "average_household_income_change_upper": -105.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000076", "constituency_name": "Constituency 76", - "x": 13, - "y": 2, + "x": 35, + "y": 1, "average_household_income_change": -127.30625312048672, "relative_household_income_change": 1e-12, "population": 117467.31956453231 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1043.0, + "average_household_income_change_upper": 1419.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000077", "constituency_name": "Constituency 77", - "x": 14, - "y": 2, + "x": 36, + "y": 1, "average_household_income_change": 1230.791969781145, "relative_household_income_change": -3.5e-9, "population": 148070.92092286033 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 814.0, + "average_household_income_change_upper": 1109.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000078", "constituency_name": "Constituency 78", - "x": null, - "y": null, + "x": 37, + "y": 1, "average_household_income_change": 961.4866644663272, "relative_household_income_change": 0.0, "population": 102372.13679715489 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 471.0, + "average_household_income_change_upper": 646.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000079", "constituency_name": "Constituency 79", - "x": 16, - "y": 2, + "x": 38, + "y": 1, "average_household_income_change": 558.5324418439463, "relative_household_income_change": 0.0, "population": 80215.38631281923 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1233.0, + "average_household_income_change_upper": 1677.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000080", "constituency_name": "Constituency 80", - "x": 17, - "y": 2, + "x": 39, + "y": 1, "average_household_income_change": 1454.8518394753298, "relative_household_income_change": -3.5e-9, "population": 96129.88324153051 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2299.0, + "average_household_income_change_upper": -1693.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000081", "constituency_name": "Constituency 81", - "x": 18, + "x": 0, "y": 2, "average_household_income_change": -1995.8700108929918, "relative_household_income_change": -0.0, "population": 117530.89833643334 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -760.0, + "average_household_income_change_upper": -556.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000082", "constituency_name": "Constituency 82", - "x": 19, + "x": 1, "y": 2, "average_household_income_change": -658.2352550207946, "relative_household_income_change": 0.0, "population": 91829.77214309663 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 157.0, + "average_household_income_change_upper": 220.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000083", "constituency_name": "Constituency 83", - "x": 20, + "x": 2, "y": 2, "average_household_income_change": 188.26514420342198, "relative_household_income_change": 1e-12, "population": 66881.29106756164 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 290.0, + "average_household_income_change_upper": 401.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000084", "constituency_name": "Constituency 84", - "x": 21, + "x": 3, "y": 2, "average_household_income_change": 345.8706592634417, "relative_household_income_change": 1e-12, "population": 56080.595832210536 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1928.0, + "average_household_income_change_upper": -1419.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000085", "constituency_name": "Constituency 85", - "x": null, - "y": null, + "x": 4, + "y": 2, "average_household_income_change": -1673.2370069478045, "relative_household_income_change": -3.5e-9, "population": 132596.5586019211 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -150.0, + "average_household_income_change_upper": -105.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000086", "constituency_name": "Constituency 86", - "x": 23, + "x": 5, "y": 2, "average_household_income_change": -127.34504781316191, "relative_household_income_change": -0.0, "population": 108141.83862604474 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1538.0, + "average_household_income_change_upper": -1130.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000087", "constituency_name": "Constituency 87", - "x": 24, + "x": 6, "y": 2, "average_household_income_change": -1334.0686487199855, "relative_household_income_change": -0.0, "population": 64208.1603564976 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 764.0, + "average_household_income_change_upper": 1042.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000088", "constituency_name": "Constituency 88", - "x": 25, + "x": 7, "y": 2, "average_household_income_change": 902.6394274385958, "relative_household_income_change": 0.0, "population": 96826.50596375517 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -693.0, + "average_household_income_change_upper": -506.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000089", "constituency_name": "Constituency 89", - "x": 26, + "x": 8, "y": 2, "average_household_income_change": -599.3606996628422, "relative_household_income_change": 1e-12, "population": 73495.08876254177 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1472.0, + "average_household_income_change_upper": 2000.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000090", "constituency_name": "Constituency 90", - "x": 27, + "x": 9, "y": 2, "average_household_income_change": 1736.004403868296, "relative_household_income_change": 0.0, "population": 72671.92918410263 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -215.0, + "average_household_income_change_upper": -153.0, + "relative_household_income_change_lower": 0.3719, + "relative_household_income_change_upper": 0.5042, "constituency_code": "E14000000091", "constituency_name": "Constituency 91", - "x": 28, + "x": 10, "y": 2, "average_household_income_change": -183.77106589635696, "relative_household_income_change": 0.43806674434402326, "population": 114528.8279064073 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 633.0, + "average_household_income_change_upper": 864.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000092", "constituency_name": "Constituency 92", - "x": null, - "y": null, + "x": 11, + "y": 2, "average_household_income_change": 748.6525324379664, "relative_household_income_change": -0.0, "population": 115796.32346101322 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 744.0, + "average_household_income_change_upper": 1015.0, + "relative_household_income_change_lower": 0.8336, + "relative_household_income_change_upper": 1.1289, "constituency_code": "E14000000093", "constituency_name": "Constituency 93", - "x": 30, + "x": 12, "y": 2, "average_household_income_change": 879.2492998869496, "relative_household_income_change": 0.9812499439637178, "population": 72504.37458082387 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 72.0, + "average_household_income_change_upper": 106.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000094", "constituency_name": "Constituency 94", - "x": 0, - "y": 3, + "x": 13, + "y": 2, "average_household_income_change": 88.85783739759168, "relative_household_income_change": -0.0, "population": 98878.56897288407 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1397.0, + "average_household_income_change_upper": 1898.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000095", "constituency_name": "Constituency 95", - "x": 1, - "y": 3, + "x": 14, + "y": 2, "average_household_income_change": 1647.1065576113149, "relative_household_income_change": 1e-12, "population": 143872.09389896045 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 847.0, + "average_household_income_change_upper": 1154.0, + "relative_household_income_change_lower": 0.2524, + "relative_household_income_change_upper": 0.3426, "constituency_code": "E14000000096", "constituency_name": "Constituency 96", - "x": 2, - "y": 3, + "x": 15, + "y": 2, "average_household_income_change": 1000.4981954112053, "relative_household_income_change": 0.2974922761899098, "population": 95587.75122445801 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 559.0, + "average_household_income_change_upper": 765.0, + "relative_household_income_change_lower": 0.2089, + "relative_household_income_change_upper": 0.2836, "constituency_code": "E14000000097", "constituency_name": "Constituency 97", - "x": 3, - "y": 3, + "x": 16, + "y": 2, "average_household_income_change": 662.0195760611831, "relative_household_income_change": 0.24624440427242644, "population": 74596.90118944892 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1119.0, + "average_household_income_change_upper": -821.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000098", "constituency_name": "Constituency 98", - "x": 4, - "y": 3, + "x": 17, + "y": 2, "average_household_income_change": -969.5701848891072, "relative_household_income_change": -3.5e-9, "population": 119650.20784516653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1391.0, + "average_household_income_change_upper": 1890.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000099", "constituency_name": "Constituency 99", - "x": null, - "y": null, + "x": 18, + "y": 2, "average_household_income_change": 1640.698557999197, "relative_household_income_change": 1e-12, "population": 65758.83878464595 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2082.0, + "average_household_income_change_upper": -1533.0, + "relative_household_income_change_lower": 0.1766, + "relative_household_income_change_upper": 0.24, "constituency_code": "E14000000100", "constituency_name": "Constituency 100", - "x": 6, - "y": 3, + "x": 19, + "y": 2, "average_household_income_change": -1807.137308302433, "relative_household_income_change": 0.20830978548035517, "population": 130632.53138420294 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 209.0, + "average_household_income_change_upper": 291.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000101", "constituency_name": "Constituency 101", - "x": 7, - "y": 3, + "x": 20, + "y": 2, "average_household_income_change": 249.5946512422913, "relative_household_income_change": 0.0, "population": 117617.37454965772 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1446.0, + "average_household_income_change_upper": 1964.0, + "relative_household_income_change_lower": 0.0336, + "relative_household_income_change_upper": 0.0465, "constituency_code": "E14000000102", "constituency_name": "Constituency 102", - "x": 8, - "y": 3, + "x": 21, + "y": 2, "average_household_income_change": 1704.7622150184222, "relative_household_income_change": 0.040006378333672155, "population": 91743.07750685193 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1121.0, + "average_household_income_change_upper": 1525.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000103", "constituency_name": "Constituency 103", - "x": 9, - "y": 3, + "x": 22, + "y": 2, "average_household_income_change": 1323.0082082453052, "relative_household_income_change": -0.0, "population": 82625.82863494732 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -635.0, + "average_household_income_change_upper": -463.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000104", "constituency_name": "Constituency 104", - "x": 10, - "y": 3, + "x": 23, + "y": 2, "average_household_income_change": -548.9345688529448, "relative_household_income_change": 0.0, "population": 106252.55590308961 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1263.0, + "average_household_income_change_upper": 1717.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000105", "constituency_name": "Constituency 105", - "x": 11, - "y": 3, + "x": 24, + "y": 2, "average_household_income_change": 1490.3340145481927, "relative_household_income_change": -0.0, "population": 52780.212642555765 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -131.0, + "average_household_income_change_upper": -91.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000106", "constituency_name": "Constituency 106", - "x": null, - "y": null, + "x": 25, + "y": 2, "average_household_income_change": -110.74050107692868, "relative_household_income_change": -0.0, "population": 132857.81166528582 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 452.0, + "average_household_income_change_upper": 620.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000107", "constituency_name": "Constituency 107", - "x": 13, - "y": 3, + "x": 26, + "y": 2, "average_household_income_change": 535.7247779833637, "relative_household_income_change": 1e-12, "population": 70233.9136272132 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1800.0, + "average_household_income_change_upper": -1324.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000108", "constituency_name": "Constituency 108", - "x": 14, - "y": 3, + "x": 27, + "y": 2, "average_household_income_change": -1562.196706775103, "relative_household_income_change": 1e-12, "population": 57613.888020112136 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -932.0, + "average_household_income_change_upper": -683.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000109", "constituency_name": "Constituency 109", - "x": 15, - "y": 3, + "x": 28, + "y": 2, "average_household_income_change": -807.5046810198771, "relative_household_income_change": 0.0, "population": 59873.63482115029 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 704.0, + "average_household_income_change_upper": 960.0, + "relative_household_income_change_lower": 0.6698, + "relative_household_income_change_upper": 0.9072, "constituency_code": "E14000000110", "constituency_name": "Constituency 110", - "x": 16, - "y": 3, + "x": 29, + "y": 2, "average_household_income_change": 831.9248746300553, "relative_household_income_change": 0.7884886691726886, "population": 118598.10011798801 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1874.0, + "average_household_income_change_upper": -1379.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000111", "constituency_name": "Constituency 111", - "x": 17, - "y": 3, + "x": 30, + "y": 2, "average_household_income_change": -1626.7832810843204, "relative_household_income_change": 1e-12, "population": 119305.13175442934 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1212.0, + "average_household_income_change_upper": 1648.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000112", "constituency_name": "Constituency 112", - "x": 18, - "y": 3, + "x": 31, + "y": 2, "average_household_income_change": 1429.6328025898952, "relative_household_income_change": 1e-12, "population": 102121.6917442946 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1550.0, + "average_household_income_change_upper": -1139.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000113", "constituency_name": "Constituency 113", - "x": null, - "y": null, + "x": 32, + "y": 2, "average_household_income_change": -1344.590914850559, "relative_household_income_change": 0.0, "population": 62858.80426314438 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1322.0, + "average_household_income_change_upper": -971.0, + "relative_household_income_change_lower": 0.4957, + "relative_household_income_change_upper": 0.6718, "constituency_code": "E14000000114", "constituency_name": "Constituency 114", - "x": 20, - "y": 3, + "x": 33, + "y": 2, "average_household_income_change": -1146.3259480815013, "relative_household_income_change": 0.5837592442382298, "population": 122906.79170362945 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 761.0, + "average_household_income_change_upper": 1037.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000115", "constituency_name": "Constituency 115", - "x": 21, - "y": 3, + "x": 34, + "y": 2, "average_household_income_change": 898.8663515055783, "relative_household_income_change": 0.0, "population": 99534.98962730182 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1458.0, + "average_household_income_change_upper": 1981.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000116", "constituency_name": "Constituency 116", - "x": 22, - "y": 3, + "x": 35, + "y": 2, "average_household_income_change": 1719.4014329329482, "relative_household_income_change": -3.5e-9, "population": 69591.52802599104 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 475.0, + "average_household_income_change_upper": 650.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000117", "constituency_name": "Constituency 117", - "x": 23, - "y": 3, + "x": 36, + "y": 2, "average_household_income_change": 562.4608552702921, "relative_household_income_change": 0.0, "population": 55842.24291191352 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2074.0, + "average_household_income_change_upper": -1527.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000118", "constituency_name": "Constituency 118", - "x": 24, - "y": 3, + "x": 37, + "y": 2, "average_household_income_change": -1800.665380166868, "relative_household_income_change": 0.0, "population": 58657.962368604676 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2095.0, + "average_household_income_change_upper": -1542.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000119", "constituency_name": "Constituency 119", - "x": 25, - "y": 3, + "x": 38, + "y": 2, "average_household_income_change": -1818.6883148220843, "relative_household_income_change": -0.0, "population": 137813.88099731703 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 384.0, + "average_household_income_change_upper": 528.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000120", "constituency_name": "Constituency 120", - "x": null, - "y": null, + "x": 39, + "y": 2, "average_household_income_change": 455.83119363301086, "relative_household_income_change": 1e-12, "population": 57970.009217312014 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1512.0, + "average_household_income_change_upper": 2054.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000121", "constituency_name": "Constituency 121", - "x": 27, + "x": 0, "y": 3, "average_household_income_change": 1782.7850710537834, "relative_household_income_change": 0.0, "population": 143164.58131962447 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1524.0, + "average_household_income_change_upper": -1120.0, + "relative_household_income_change_lower": 0.4602, + "relative_household_income_change_upper": 0.6237, "constituency_code": "E14000000122", "constituency_name": "Constituency 122", - "x": 28, + "x": 1, "y": 3, "average_household_income_change": -1321.9039327565874, "relative_household_income_change": 0.541962411948292, "population": 50457.04856292268 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1854.0, + "average_household_income_change_upper": -1364.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000123", "constituency_name": "Constituency 123", - "x": 29, + "x": 2, "y": 3, "average_household_income_change": -1609.0297193715548, "relative_household_income_change": 1e-12, "population": 135630.50062274272 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1267.0, + "average_household_income_change_upper": 1722.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000124", "constituency_name": "Constituency 124", - "x": 30, + "x": 3, "y": 3, "average_household_income_change": 1494.6489038444706, "relative_household_income_change": -0.0, "population": 86499.87244664696 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -237.0, + "average_household_income_change_upper": -169.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000125", "constituency_name": "Constituency 125", - "x": 0, - "y": 4, + "x": 4, + "y": 3, "average_household_income_change": -203.1025816503482, "relative_household_income_change": -3.5e-9, "population": 131450.3581071367 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2000.0, + "average_household_income_change_upper": -1472.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000126", "constituency_name": "Constituency 126", - "x": 1, - "y": 4, + "x": 5, + "y": 3, "average_household_income_change": -1736.00876747027, "relative_household_income_change": 1e-12, "population": 77714.90708517047 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 619.0, + "average_household_income_change_upper": 846.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000127", "constituency_name": "Constituency 127", - "x": null, - "y": null, + "x": 6, + "y": 3, "average_household_income_change": 732.7271206158956, "relative_household_income_change": 0.0, "population": 62311.51182123555 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 642.0, + "average_household_income_change_upper": 876.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000128", "constituency_name": "Constituency 128", - "x": 3, - "y": 4, + "x": 7, + "y": 3, "average_household_income_change": 759.0741072953095, "relative_household_income_change": 1e-12, "population": 148750.0421308927 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -133.0, + "average_household_income_change_upper": -92.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000129", "constituency_name": "Constituency 129", - "x": 4, - "y": 4, + "x": 8, + "y": 3, "average_household_income_change": -112.66921174672939, "relative_household_income_change": -0.0, "population": 143434.03244226988 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 584.0, + "average_household_income_change_upper": 798.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000130", "constituency_name": "Constituency 130", - "x": 5, - "y": 4, + "x": 9, + "y": 3, "average_household_income_change": 691.029924028856, "relative_household_income_change": 1e-12, "population": 99922.79421022549 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1222.0, + "average_household_income_change_upper": 1662.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000131", "constituency_name": "Constituency 131", - "x": 6, - "y": 4, + "x": 10, + "y": 3, "average_household_income_change": 1441.9814618812738, "relative_household_income_change": -3.5e-9, "population": 131523.50923887704 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1649.0, + "average_household_income_change_upper": -1212.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000132", "constituency_name": "Constituency 132", - "x": 7, - "y": 4, + "x": 11, + "y": 3, "average_household_income_change": -1430.5752926829505, "relative_household_income_change": -3.5e-9, "population": 137924.9037228661 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -618.0, + "average_household_income_change_upper": -451.0, + "relative_household_income_change_lower": 0.6396, + "relative_household_income_change_upper": 0.8664, "constituency_code": "E14000000133", "constituency_name": "Constituency 133", - "x": 8, - "y": 4, + "x": 12, + "y": 3, "average_household_income_change": -534.625803263033, "relative_household_income_change": 0.7529910574305773, "population": 148437.97322438212 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 49.0, + "average_household_income_change_upper": 74.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000134", "constituency_name": "Constituency 134", - "x": null, - "y": null, + "x": 13, + "y": 3, "average_household_income_change": 61.61400773596615, "relative_household_income_change": -0.0, "population": 111496.92620513316 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1616.0, + "average_household_income_change_upper": 2195.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000135", "constituency_name": "Constituency 135", - "x": 10, - "y": 4, + "x": 14, + "y": 3, "average_household_income_change": 1905.6857330111927, "relative_household_income_change": -3.5e-9, "population": 138584.5575915625 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1319.0, + "average_household_income_change_upper": -969.0, + "relative_household_income_change_lower": 0.2485, + "relative_household_income_change_upper": 0.3373, "constituency_code": "E14000000136", "constituency_name": "Constituency 136", - "x": 11, - "y": 4, + "x": 15, + "y": 3, "average_household_income_change": -1144.1054423786206, "relative_household_income_change": 0.2928856424926053, "population": 53197.31312326364 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1458.0, + "average_household_income_change_upper": -1072.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000137", "constituency_name": "Constituency 137", - "x": 12, - "y": 4, + "x": 16, + "y": 3, "average_household_income_change": -1265.1713617127114, "relative_household_income_change": -3.5e-9, "population": 86572.81347329413 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -601.0, + "average_household_income_change_upper": -438.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000138", "constituency_name": "Constituency 138", - "x": 13, - "y": 4, + "x": 17, + "y": 3, "average_household_income_change": -519.7588256792973, "relative_household_income_change": 1e-12, "population": 71191.46671555749 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 134.0, + "average_household_income_change_upper": 190.0, + "relative_household_income_change_lower": 0.7526, + "relative_household_income_change_upper": 1.0192, "constituency_code": "E14000000139", "constituency_name": "Constituency 139", - "x": 14, - "y": 4, + "x": 18, + "y": 3, "average_household_income_change": 162.22254008254345, "relative_household_income_change": 0.8858936695673891, "population": 96442.4130437495 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -282.0, + "average_household_income_change_upper": -203.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000140", "constituency_name": "Constituency 140", - "x": 15, - "y": 4, + "x": 19, + "y": 3, "average_household_income_change": -242.46320601773473, "relative_household_income_change": 0.0, "population": 80110.23234374468 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 690.0, + "average_household_income_change_upper": 942.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000141", "constituency_name": "Constituency 141", - "x": null, - "y": null, + "x": 20, + "y": 3, "average_household_income_change": 816.202709092623, "relative_household_income_change": 1e-12, "population": 73590.60951765552 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -962.0, + "average_household_income_change_upper": -705.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000142", "constituency_name": "Constituency 142", - "x": 17, - "y": 4, + "x": 21, + "y": 3, "average_household_income_change": -833.1977474989549, "relative_household_income_change": 0.0, "population": 61505.33961873027 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 983.0, + "average_household_income_change_upper": 1339.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000143", "constituency_name": "Constituency 143", - "x": 18, - "y": 4, + "x": 22, + "y": 3, "average_household_income_change": 1160.9421061865896, "relative_household_income_change": 0.0, "population": 115466.82296615103 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1047.0, + "average_household_income_change_upper": -767.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000144", "constituency_name": "Constituency 144", - "x": 19, - "y": 4, + "x": 23, + "y": 3, "average_household_income_change": -907.0148122290055, "relative_household_income_change": -0.0, "population": 146736.6126186456 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 27.0, + "average_household_income_change_upper": 44.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000145", "constituency_name": "Constituency 145", - "x": 20, - "y": 4, + "x": 24, + "y": 3, "average_household_income_change": 35.53462916544747, "relative_household_income_change": 0.0, "population": 64441.56694242156 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -147.0, + "average_household_income_change_upper": -102.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000146", "constituency_name": "Constituency 146", - "x": 21, - "y": 4, + "x": 25, + "y": 3, "average_household_income_change": -124.46281500682232, "relative_household_income_change": -0.0, "population": 120349.10959497117 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -995.0, + "average_household_income_change_upper": -729.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000147", "constituency_name": "Constituency 147", - "x": 22, - "y": 4, + "x": 26, + "y": 3, "average_household_income_change": -862.1979283911992, "relative_household_income_change": 1e-12, "population": 75978.88726544433 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 966.0, + "average_household_income_change_upper": 1315.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000148", "constituency_name": "Constituency 148", - "x": null, - "y": null, + "x": 27, + "y": 3, "average_household_income_change": 1140.4649528680948, "relative_household_income_change": 0.0, "population": 54047.383882475355 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 573.0, + "average_household_income_change_upper": 784.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000149", "constituency_name": "Constituency 149", - "x": 24, - "y": 4, + "x": 28, + "y": 3, "average_household_income_change": 678.6107114798727, "relative_household_income_change": -3.5e-9, "population": 117413.54107700937 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1117.0, + "average_household_income_change_upper": -820.0, + "relative_household_income_change_lower": 0.3213, + "relative_household_income_change_upper": 0.4358, "constituency_code": "E14000000150", "constituency_name": "Constituency 150", - "x": 25, - "y": 4, + "x": 29, + "y": 3, "average_household_income_change": -968.559432686301, "relative_household_income_change": 0.37852725111076435, "population": 115903.15108720168 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1302.0, + "average_household_income_change_upper": -956.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000151", "constituency_name": "Constituency 151", - "x": 26, - "y": 4, + "x": 30, + "y": 3, "average_household_income_change": -1129.3003769282795, "relative_household_income_change": -3.5e-9, "population": 144612.66162939422 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1075.0, + "average_household_income_change_upper": 1463.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000152", "constituency_name": "Constituency 152", - "x": 27, - "y": 4, + "x": 31, + "y": 3, "average_household_income_change": 1269.0375684969467, "relative_household_income_change": -3.5e-9, "population": 103290.76445801418 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -561.0, + "average_household_income_change_upper": -408.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000153", "constituency_name": "Constituency 153", - "x": 28, - "y": 4, + "x": 32, + "y": 3, "average_household_income_change": -484.59589825511887, "relative_household_income_change": 0.0, "population": 78314.43303172 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1724.0, + "average_household_income_change_upper": -1268.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000154", "constituency_name": "Constituency 154", - "x": 29, - "y": 4, + "x": 33, + "y": 3, "average_household_income_change": -1495.7935523863468, "relative_household_income_change": -0.0, "population": 64071.97759800385 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -452.0, + "average_household_income_change_upper": -328.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000155", "constituency_name": "Constituency 155", - "x": null, - "y": null, + "x": 34, + "y": 3, "average_household_income_change": -389.87763296794054, "relative_household_income_change": 0.0, "population": 54102.4304805369 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1393.0, + "average_household_income_change_upper": 1894.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000156", "constituency_name": "Constituency 156", - "x": 0, - "y": 5, + "x": 35, + "y": 3, "average_household_income_change": 1643.525970306858, "relative_household_income_change": 0.0, "population": 114985.93268203211 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 425.0, + "average_household_income_change_upper": 583.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000157", "constituency_name": "Constituency 157", - "x": 1, - "y": 5, + "x": 36, + "y": 3, "average_household_income_change": 504.13911589940864, "relative_household_income_change": 1e-12, "population": 96530.2084560394 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1487.0, + "average_household_income_change_upper": 2021.0, + "relative_household_income_change_lower": 0.1464, + "relative_household_income_change_upper": 0.1991, "constituency_code": "E14000000158", "constituency_name": "Constituency 158", - "x": 2, - "y": 5, + "x": 37, + "y": 3, "average_household_income_change": 1753.9239358191967, "relative_household_income_change": 0.17275558712005012, "population": 63841.377140504985 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1907.0, + "average_household_income_change_upper": -1404.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000159", "constituency_name": "Constituency 159", - "x": 3, - "y": 5, + "x": 38, + "y": 3, "average_household_income_change": -1655.610381708632, "relative_household_income_change": 1e-12, "population": 59182.04001348608 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1266.0, + "average_household_income_change_upper": 1721.0, + "relative_household_income_change_lower": 0.0435, + "relative_household_income_change_upper": 0.0599, "constituency_code": "E14000000160", "constituency_name": "Constituency 160", - "x": 4, - "y": 5, + "x": 39, + "y": 3, "average_household_income_change": 1493.1892934146344, "relative_household_income_change": 0.05167241091737229, "population": 134883.8787827266 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1165.0, + "average_household_income_change_upper": -855.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000161", "constituency_name": "Constituency 161", - "x": 5, - "y": 5, + "x": 0, + "y": 4, "average_household_income_change": -1009.785320605499, "relative_household_income_change": -0.0, "population": 105313.0376348572 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1684.0, + "average_household_income_change_upper": 2287.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000162", "constituency_name": "Constituency 162", - "x": null, - "y": null, + "x": 1, + "y": 4, "average_household_income_change": 1985.790511051385, "relative_household_income_change": 0.0, "population": 59046.08239782301 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -721.0, + "average_household_income_change_upper": -527.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000163", "constituency_name": "Constituency 163", - "x": 7, - "y": 5, + "x": 2, + "y": 4, "average_household_income_change": -624.0407357817598, "relative_household_income_change": -3.5e-9, "population": 133938.68480033753 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1406.0, + "average_household_income_change_upper": -1033.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000164", "constituency_name": "Constituency 164", - "x": 8, - "y": 5, + "x": 3, + "y": 4, "average_household_income_change": -1219.380899954297, "relative_household_income_change": -3.5e-9, "population": 140688.8228403655 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 916.0, + "average_household_income_change_upper": 1248.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000165", "constituency_name": "Constituency 165", - "x": 9, - "y": 5, + "x": 4, + "y": 4, "average_household_income_change": 1082.0360022946556, "relative_household_income_change": -0.0, "population": 119206.25377648813 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1077.0, + "average_household_income_change_upper": 1465.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000166", "constituency_name": "Constituency 166", - "x": 10, - "y": 5, + "x": 5, + "y": 4, "average_household_income_change": 1270.7265069357318, "relative_household_income_change": 0.0, "population": 60177.53130640996 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -203.0, + "average_household_income_change_upper": -144.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000167", "constituency_name": "Constituency 167", - "x": 11, - "y": 5, + "x": 6, + "y": 4, "average_household_income_change": -173.76753307330173, "relative_household_income_change": -3.5e-9, "population": 92609.98355546329 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 68.0, + "average_household_income_change_upper": 101.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000168", "constituency_name": "Constituency 168", - "x": 12, - "y": 5, + "x": 7, + "y": 4, "average_household_income_change": 84.60524462623465, "relative_household_income_change": 0.0, "population": 109246.06859637471 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1039.0, + "average_household_income_change_upper": 1414.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000169", "constituency_name": "Constituency 169", - "x": null, - "y": null, + "x": 8, + "y": 4, "average_household_income_change": 1226.4575806689, "relative_household_income_change": -3.5e-9, "population": 85779.77623277435 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1610.0, + "average_household_income_change_upper": 2186.0, + "relative_household_income_change_lower": 0.3118, + "relative_household_income_change_upper": 0.4229, "constituency_code": "E14000000170", "constituency_name": "Constituency 170", - "x": 14, - "y": 5, + "x": 9, + "y": 4, "average_household_income_change": 1897.8839697841004, "relative_household_income_change": 0.3673733728077122, "population": 134526.85600579536 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -115.0, + "average_household_income_change_upper": -79.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000171", "constituency_name": "Constituency 171", - "x": 15, - "y": 5, + "x": 10, + "y": 4, "average_household_income_change": -96.71162396045486, "relative_household_income_change": -0.0, "population": 135267.77830307873 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1471.0, + "average_household_income_change_upper": 1999.0, + "relative_household_income_change_lower": 0.6378, + "relative_household_income_change_upper": 0.8639, "constituency_code": "E14000000172", "constituency_name": "Constituency 172", - "x": 16, - "y": 5, + "x": 11, + "y": 4, "average_household_income_change": 1734.8446483630125, "relative_household_income_change": 0.7508561605283052, "population": 121288.32325809612 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 946.0, + "average_household_income_change_upper": 1288.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000173", "constituency_name": "Constituency 173", - "x": 17, - "y": 5, + "x": 12, + "y": 4, "average_household_income_change": 1116.8285362297524, "relative_household_income_change": 0.0, "population": 143666.5638784498 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1468.0, + "average_household_income_change_upper": 1994.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000174", "constituency_name": "Constituency 174", - "x": 18, - "y": 5, + "x": 13, + "y": 4, "average_household_income_change": 1731.2154456492808, "relative_household_income_change": -3.5e-9, "population": 124824.5757648796 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1653.0, + "average_household_income_change_upper": 2244.0, + "relative_household_income_change_lower": 0.4227, + "relative_household_income_change_upper": 0.573, "constituency_code": "E14000000175", "constituency_name": "Constituency 175", - "x": 19, - "y": 5, + "x": 14, + "y": 4, "average_household_income_change": 1948.654638465101, "relative_household_income_change": 0.4978536471952415, "population": 131100.4905236806 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -591.0, + "average_household_income_change_upper": -431.0, + "relative_household_income_change_lower": 0.1106, + "relative_household_income_change_upper": 0.1507, "constituency_code": "E14000000176", "constituency_name": "Constituency 176", - "x": null, - "y": null, + "x": 15, + "y": 4, "average_household_income_change": -511.27124006434775, "relative_household_income_change": 0.13061907743062717, "population": 116551.31582614966 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2229.0, + "average_household_income_change_upper": -1641.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000177", "constituency_name": "Constituency 177", - "x": 21, - "y": 5, + "x": 16, + "y": 4, "average_household_income_change": -1935.029384051584, "relative_household_income_change": 1e-12, "population": 137063.16851170457 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1051.0, + "average_household_income_change_upper": -771.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000178", "constituency_name": "Constituency 178", - "x": 22, - "y": 5, + "x": 17, + "y": 4, "average_household_income_change": -911.186276780501, "relative_household_income_change": -0.0, "population": 61965.39400983384 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1470.0, + "average_household_income_change_upper": 1997.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000179", "constituency_name": "Constituency 179", - "x": 23, - "y": 5, + "x": 18, + "y": 4, "average_household_income_change": 1733.1720235382163, "relative_household_income_change": 0.0, "population": 142246.5510827012 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1409.0, + "average_household_income_change_upper": 1914.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000180", "constituency_name": "Constituency 180", - "x": 24, - "y": 5, + "x": 19, + "y": 4, "average_household_income_change": 1661.238755153409, "relative_household_income_change": 0.0, "population": 90367.91791250638 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 290.0, + "average_household_income_change_upper": 401.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000181", "constituency_name": "Constituency 181", - "x": 25, - "y": 5, + "x": 20, + "y": 4, "average_household_income_change": 345.2949680294841, "relative_household_income_change": -3.5e-9, "population": 149980.7029767488 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1140.0, + "average_household_income_change_upper": -836.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000182", "constituency_name": "Constituency 182", - "x": 26, - "y": 5, + "x": 21, + "y": 4, "average_household_income_change": -988.0666839819572, "relative_household_income_change": -0.0, "population": 128097.92539036271 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 763.0, + "average_household_income_change_upper": 1041.0, + "relative_household_income_change_lower": 0.0779, + "relative_household_income_change_upper": 0.1065, "constituency_code": "E14000000183", "constituency_name": "Constituency 183", - "x": null, - "y": null, + "x": 22, + "y": 4, "average_household_income_change": 902.1430784366627, "relative_household_income_change": 0.09220506661790073, "population": 85359.46880565214 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1105.0, + "average_household_income_change_upper": -810.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000184", "constituency_name": "Constituency 184", - "x": 28, - "y": 5, + "x": 23, + "y": 4, "average_household_income_change": -957.5128615802955, "relative_household_income_change": 0.0, "population": 57657.65475740139 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -870.0, + "average_household_income_change_upper": -637.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000185", "constituency_name": "Constituency 185", - "x": 29, - "y": 5, + "x": 24, + "y": 4, "average_household_income_change": -753.5040279733156, "relative_household_income_change": 1e-12, "population": 98478.87085938707 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1403.0, + "average_household_income_change_upper": 1907.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000186", "constituency_name": "Constituency 186", - "x": 30, - "y": 5, + "x": 25, + "y": 4, "average_household_income_change": 1655.0348378318804, "relative_household_income_change": -0.0, "population": 140589.08503782647 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -530.0, + "average_household_income_change_upper": -386.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000187", "constituency_name": "Constituency 187", - "x": 0, - "y": 6, + "x": 26, + "y": 4, "average_household_income_change": -457.6924571642285, "relative_household_income_change": -3.5e-9, "population": 60506.32404568623 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 809.0, + "average_household_income_change_upper": 1102.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000188", "constituency_name": "Constituency 188", - "x": 1, - "y": 6, + "x": 27, + "y": 4, "average_household_income_change": 955.5435083380046, "relative_household_income_change": 0.0, "population": 148637.46753157367 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -952.0, + "average_household_income_change_upper": -697.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000189", "constituency_name": "Constituency 189", - "x": 2, - "y": 6, + "x": 28, + "y": 4, "average_household_income_change": -824.5635557117255, "relative_household_income_change": 0.0, "population": 99782.64274656228 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -619.0, + "average_household_income_change_upper": -451.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000190", "constituency_name": "Constituency 190", - "x": null, - "y": null, + "x": 29, + "y": 4, "average_household_income_change": -534.8346178311823, "relative_household_income_change": 1e-12, "population": 55067.263103974285 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 952.0, + "average_household_income_change_upper": 1297.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000191", "constituency_name": "Constituency 191", - "x": 4, - "y": 6, + "x": 30, + "y": 4, "average_household_income_change": 1124.6850271956769, "relative_household_income_change": -0.0, "population": 135639.43667060742 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1624.0, + "average_household_income_change_upper": 2206.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000192", "constituency_name": "Constituency 192", - "x": 5, - "y": 6, + "x": 31, + "y": 4, "average_household_income_change": 1915.0183852603498, "relative_household_income_change": -0.0, "population": 72513.67422403721 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 395.0, + "average_household_income_change_upper": 542.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000193", "constituency_name": "Constituency 193", - "x": 6, - "y": 6, + "x": 32, + "y": 4, "average_household_income_change": 468.543806087232, "relative_household_income_change": 1e-12, "population": 51295.48380385167 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2270.0, + "average_household_income_change_upper": -1672.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000194", "constituency_name": "Constituency 194", - "x": 7, - "y": 6, + "x": 33, + "y": 4, "average_household_income_change": -1970.722745123179, "relative_household_income_change": -3.5e-9, "population": 90662.16267914974 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1307.0, + "average_household_income_change_upper": 1777.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000195", "constituency_name": "Constituency 195", - "x": 8, - "y": 6, + "x": 34, + "y": 4, "average_household_income_change": 1541.7517077241403, "relative_household_income_change": 0.0, "population": 51287.49142035601 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -649.0, + "average_household_income_change_upper": -473.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000196", "constituency_name": "Constituency 196", - "x": 9, - "y": 6, + "x": 35, + "y": 4, "average_household_income_change": -561.0043898564228, "relative_household_income_change": -0.0, "population": 148348.3321363182 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 717.0, + "average_household_income_change_upper": 979.0, + "relative_household_income_change_lower": 0.1024, + "relative_household_income_change_upper": 0.1396, "constituency_code": "E14000000197", "constituency_name": "Constituency 197", - "x": null, - "y": null, + "x": 36, + "y": 4, "average_household_income_change": 847.9949757046952, "relative_household_income_change": 0.12100126791021937, "population": 146252.67973033 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -145.0, + "average_household_income_change_upper": -101.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000198", "constituency_name": "Constituency 198", - "x": 11, - "y": 6, + "x": 37, + "y": 4, "average_household_income_change": -122.91164421650168, "relative_household_income_change": 0.0, "population": 108822.50841263078 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 175.0, + "average_household_income_change_upper": 245.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000199", "constituency_name": "Constituency 199", - "x": 12, - "y": 6, + "x": 38, + "y": 4, "average_household_income_change": 210.35075187056896, "relative_household_income_change": 1e-12, "population": 73060.15474275278 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1164.0, + "average_household_income_change_upper": 1583.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000200", "constituency_name": "Constituency 200", - "x": 13, - "y": 6, + "x": 39, + "y": 4, "average_household_income_change": 1373.2772747493455, "relative_household_income_change": -3.5e-9, "population": 54562.39026607954 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1506.0, + "average_household_income_change_upper": 2046.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000201", "constituency_name": "Constituency 201", - "x": 14, - "y": 6, + "x": 0, + "y": 5, "average_household_income_change": 1775.7579273582728, "relative_household_income_change": -0.0, "population": 68571.93485422268 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 411.0, + "average_household_income_change_upper": 565.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000202", "constituency_name": "Constituency 202", - "x": 15, - "y": 6, + "x": 1, + "y": 5, "average_household_income_change": 487.9521355800094, "relative_household_income_change": 1e-12, "population": 52440.31636393422 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1524.0, + "average_household_income_change_upper": -1120.0, + "relative_household_income_change_lower": 0.6772, + "relative_household_income_change_upper": 0.9172, "constituency_code": "E14000000203", "constituency_name": "Constituency 203", - "x": 16, - "y": 6, + "x": 2, + "y": 5, "average_household_income_change": -1322.0472054070465, "relative_household_income_change": 0.7971877766379147, "population": 135070.588467467 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1280.0, + "average_household_income_change_upper": 1740.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000204", "constituency_name": "Constituency 204", - "x": null, - "y": null, + "x": 3, + "y": 5, "average_household_income_change": 1509.8157970664406, "relative_household_income_change": 1e-12, "population": 144410.89387433697 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 692.0, + "average_household_income_change_upper": 944.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000205", "constituency_name": "Constituency 205", - "x": 18, - "y": 6, + "x": 4, + "y": 5, "average_household_income_change": 817.8577390299471, "relative_household_income_change": -0.0, "population": 110217.0617559504 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -601.0, + "average_household_income_change_upper": -438.0, + "relative_household_income_change_lower": 0.1144, + "relative_household_income_change_upper": 0.1558, "constituency_code": "E14000000206", "constituency_name": "Constituency 206", - "x": 19, - "y": 6, + "x": 5, + "y": 5, "average_household_income_change": -519.2624488298457, "relative_household_income_change": 0.13511138675068546, "population": 76651.5628612962 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1251.0, + "average_household_income_change_upper": -919.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000207", "constituency_name": "Constituency 207", - "x": 20, - "y": 6, + "x": 6, + "y": 5, "average_household_income_change": -1085.0536783564949, "relative_household_income_change": -0.0, "population": 60836.20787139632 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 253.0, + "average_household_income_change_upper": 350.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000208", "constituency_name": "Constituency 208", - "x": 21, - "y": 6, + "x": 7, + "y": 5, "average_household_income_change": 301.6568478061923, "relative_household_income_change": 0.0, "population": 87157.94506624367 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1928.0, + "average_household_income_change_upper": -1419.0, + "relative_household_income_change_lower": 0.3539, + "relative_household_income_change_upper": 0.4799, "constituency_code": "E14000000209", "constituency_name": "Constituency 209", - "x": 22, - "y": 6, + "x": 8, + "y": 5, "average_household_income_change": -1673.3597854652503, "relative_household_income_change": 0.4169266309393279, "population": 68426.82588844819 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -99.0, + "average_household_income_change_upper": -67.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000210", "constituency_name": "Constituency 210", - "x": 23, - "y": 6, + "x": 9, + "y": 5, "average_household_income_change": -83.09691011490594, "relative_household_income_change": -0.0, "population": 126920.19674232941 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 199.0, + "average_household_income_change_upper": 277.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000211", "constituency_name": "Constituency 211", - "x": null, - "y": null, + "x": 10, + "y": 5, "average_household_income_change": 237.6712620893251, "relative_household_income_change": 1e-12, "population": 60236.095478765325 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1827.0, + "average_household_income_change_upper": -1344.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000212", "constituency_name": "Constituency 212", - "x": 25, - "y": 6, + "x": 11, + "y": 5, "average_household_income_change": -1585.4887263147605, "relative_household_income_change": -0.0, "population": 116481.5354173281 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 41.0, + "average_household_income_change_upper": 64.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000213", "constituency_name": "Constituency 213", - "x": 26, - "y": 6, + "x": 12, + "y": 5, "average_household_income_change": 52.57033563292879, "relative_household_income_change": 0.0, "population": 79714.87039267518 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 116.0, + "average_household_income_change_upper": 165.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000214", "constituency_name": "Constituency 214", - "x": 27, - "y": 6, + "x": 13, + "y": 5, "average_household_income_change": 140.7077209407901, "relative_household_income_change": 1e-12, "population": 132320.27403479567 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1480.0, + "average_household_income_change_upper": -1088.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000215", "constituency_name": "Constituency 215", - "x": 28, - "y": 6, + "x": 14, + "y": 5, "average_household_income_change": -1284.1135147208838, "relative_household_income_change": -3.5e-9, "population": 106469.78321771967 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2065.0, + "average_household_income_change_upper": -1520.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000216", "constituency_name": "Constituency 216", - "x": 29, - "y": 6, + "x": 15, + "y": 5, "average_household_income_change": -1792.2895026530314, "relative_household_income_change": -0.0, "population": 99990.59878057335 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -905.0, + "average_household_income_change_upper": -663.0, + "relative_household_income_change_lower": 0.2451, + "relative_household_income_change_upper": 0.3327, "constituency_code": "E14000000217", "constituency_name": "Constituency 217", - "x": 30, - "y": 6, + "x": 16, + "y": 5, "average_household_income_change": -783.8278631034128, "relative_household_income_change": 0.28888357094563333, "population": 110733.83910680971 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 542.0, + "average_household_income_change_upper": 742.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000218", "constituency_name": "Constituency 218", - "x": null, - "y": null, + "x": 17, + "y": 5, "average_household_income_change": 642.037289695729, "relative_household_income_change": -0.0, "population": 90348.9414403198 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2081.0, + "average_household_income_change_upper": -1532.0, + "relative_household_income_change_lower": 0.0706, + "relative_household_income_change_upper": 0.0966, "constituency_code": "E14000000219", "constituency_name": "Constituency 219", - "x": 1, - "y": 7, + "x": 18, + "y": 5, "average_household_income_change": -1806.0966714650708, "relative_household_income_change": 0.08357134950427492, "population": 141405.8939185595 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -611.0, + "average_household_income_change_upper": -445.0, + "relative_household_income_change_lower": 0.4128, + "relative_household_income_change_upper": 0.5595, "constituency_code": "E14000000220", "constituency_name": "Constituency 220", - "x": 2, - "y": 7, + "x": 19, + "y": 5, "average_household_income_change": -528.2119277643891, "relative_household_income_change": 0.4861389513239871, "population": 71685.69023793048 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 805.0, + "average_household_income_change_upper": 1098.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000221", "constituency_name": "Constituency 221", - "x": 3, - "y": 7, + "x": 20, + "y": 5, "average_household_income_change": 951.5333154614805, "relative_household_income_change": -0.0, "population": 79177.23029313763 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 706.0, + "average_household_income_change_upper": 963.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000222", "constituency_name": "Constituency 222", - "x": 4, - "y": 7, + "x": 21, + "y": 5, "average_household_income_change": 834.3379193338874, "relative_household_income_change": 1e-12, "population": 110931.01259918352 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1403.0, + "average_household_income_change_upper": 1907.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000223", "constituency_name": "Constituency 223", - "x": 5, - "y": 7, + "x": 22, + "y": 5, "average_household_income_change": 1654.9743225435302, "relative_household_income_change": -0.0, "population": 144906.40272194232 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1573.0, + "average_household_income_change_upper": 2137.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000224", "constituency_name": "Constituency 224", - "x": 6, - "y": 7, + "x": 23, + "y": 5, "average_household_income_change": 1855.1096996562494, "relative_household_income_change": -3.5e-9, "population": 66984.07968401432 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 943.0, + "average_household_income_change_upper": 1284.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000225", "constituency_name": "Constituency 225", - "x": null, - "y": null, + "x": 24, + "y": 5, "average_household_income_change": 1113.0857713770702, "relative_household_income_change": -3.5e-9, "population": 60678.66657872668 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -368.0, + "average_household_income_change_upper": -266.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000226", "constituency_name": "Constituency 226", - "x": 8, - "y": 7, + "x": 25, + "y": 5, "average_household_income_change": -317.2806673045674, "relative_household_income_change": 1e-12, "population": 85565.2841368982 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 121.0, + "average_household_income_change_upper": 172.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000227", "constituency_name": "Constituency 227", - "x": 9, - "y": 7, + "x": 26, + "y": 5, "average_household_income_change": 146.19610700532658, "relative_household_income_change": 0.0, "population": 54058.96768105369 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1035.0, + "average_household_income_change_upper": 1408.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000228", "constituency_name": "Constituency 228", - "x": 10, - "y": 7, + "x": 27, + "y": 5, "average_household_income_change": 1221.1828572635995, "relative_household_income_change": -0.0, "population": 72968.59443746644 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -549.0, + "average_household_income_change_upper": -400.0, + "relative_household_income_change_lower": 0.381, + "relative_household_income_change_upper": 0.5166, "constituency_code": "E14000000229", "constituency_name": "Constituency 229", - "x": 11, - "y": 7, + "x": 28, + "y": 5, "average_household_income_change": -474.6414251653745, "relative_household_income_change": 0.44878570088574854, "population": 90285.80408850596 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -86.0, + "average_household_income_change_upper": -58.0, + "relative_household_income_change_lower": 0.4616, + "relative_household_income_change_upper": 0.6256, "constituency_code": "E14000000230", "constituency_name": "Constituency 230", - "x": 12, - "y": 7, + "x": 29, + "y": 5, "average_household_income_change": -71.76530955441463, "relative_household_income_change": 0.5436024592612985, "population": 141221.01934312264 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -439.0, + "average_household_income_change_upper": -318.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000231", "constituency_name": "Constituency 231", - "x": 13, - "y": 7, + "x": 30, + "y": 5, "average_household_income_change": -378.30934127288975, "relative_household_income_change": -3.5e-9, "population": 116505.42531287429 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 851.0, + "average_household_income_change_upper": 1160.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000232", "constituency_name": "Constituency 232", - "x": null, - "y": null, + "x": 31, + "y": 5, "average_household_income_change": 1005.3928185121252, "relative_household_income_change": -0.0, "population": 75058.25944668779 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1345.0, + "average_household_income_change_upper": 1828.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000233", "constituency_name": "Constituency 233", - "x": 15, - "y": 7, + "x": 32, + "y": 5, "average_household_income_change": 1586.7343046544327, "relative_household_income_change": 1e-12, "population": 67712.64242455097 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1354.0, + "average_household_income_change_upper": -995.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000234", "constituency_name": "Constituency 234", - "x": 16, - "y": 7, + "x": 33, + "y": 5, "average_household_income_change": -1174.252830775441, "relative_household_income_change": -3.5e-9, "population": 63789.03547513046 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 545.0, + "average_household_income_change_upper": 746.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000235", "constituency_name": "Constituency 235", - "x": 17, - "y": 7, + "x": 34, + "y": 5, "average_household_income_change": 645.6604038115912, "relative_household_income_change": 0.0, "population": 122263.69384295912 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -691.0, + "average_household_income_change_upper": -505.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000236", "constituency_name": "Constituency 236", - "x": 18, - "y": 7, + "x": 35, + "y": 5, "average_household_income_change": -597.6576548607908, "relative_household_income_change": -3.5e-9, "population": 98804.04918650471 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 350.0, + "average_household_income_change_upper": 481.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000237", "constituency_name": "Constituency 237", - "x": 19, - "y": 7, + "x": 36, + "y": 5, "average_household_income_change": 415.38377283407124, "relative_household_income_change": 1e-12, "population": 72462.68658428223 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -581.0, + "average_household_income_change_upper": -423.0, + "relative_household_income_change_lower": 0.3381, + "relative_household_income_change_upper": 0.4584, "constituency_code": "E14000000238", "constituency_name": "Constituency 238", - "x": 20, - "y": 7, + "x": 37, + "y": 5, "average_household_income_change": -502.30148175641, "relative_household_income_change": 0.39825500487354804, "population": 75577.16208619578 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1681.0, + "average_household_income_change_upper": 2283.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000239", "constituency_name": "Constituency 239", - "x": null, - "y": null, + "x": 38, + "y": 5, "average_household_income_change": 1982.22971422196, "relative_household_income_change": 0.0, "population": 100039.3452447531 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 55.0, + "average_household_income_change_upper": 83.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000240", "constituency_name": "Constituency 240", - "x": 22, - "y": 7, + "x": 39, + "y": 5, "average_household_income_change": 68.96842129588521, "relative_household_income_change": 0.0, "population": 78623.37294752918 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -543.0, + "average_household_income_change_upper": -395.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000241", "constituency_name": "Constituency 241", - "x": 23, - "y": 7, + "x": 0, + "y": 6, "average_household_income_change": -469.3900179759347, "relative_household_income_change": -3.5e-9, "population": 71536.75669369439 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1042.0, + "average_household_income_change_upper": 1418.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000242", "constituency_name": "Constituency 242", - "x": 24, - "y": 7, + "x": 1, + "y": 6, "average_household_income_change": 1230.3207047092353, "relative_household_income_change": -3.5e-9, "population": 63353.07779126578 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2222.0, + "average_household_income_change_upper": -1636.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000243", "constituency_name": "Constituency 243", - "x": 25, - "y": 7, + "x": 2, + "y": 6, "average_household_income_change": -1928.9219199088845, "relative_household_income_change": -3.5e-9, "population": 62849.86261697499 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 940.0, + "average_household_income_change_upper": 1280.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000244", "constituency_name": "Constituency 244", - "x": 26, - "y": 7, + "x": 3, + "y": 6, "average_household_income_change": 1110.3372470128168, "relative_household_income_change": -0.0, "population": 76727.3810569975 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1155.0, + "average_household_income_change_upper": 1571.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000245", "constituency_name": "Constituency 245", - "x": 27, - "y": 7, + "x": 4, + "y": 6, "average_household_income_change": 1362.7103896145973, "relative_household_income_change": -0.0, "population": 124325.70785994509 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -316.0, + "average_household_income_change_upper": -227.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000246", "constituency_name": "Constituency 246", - "x": null, - "y": null, + "x": 5, + "y": 6, "average_household_income_change": -271.49022713741465, "relative_household_income_change": 1e-12, "population": 99799.46872015242 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1304.0, + "average_household_income_change_upper": 1772.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000247", "constituency_name": "Constituency 247", - "x": 29, - "y": 7, + "x": 6, + "y": 6, "average_household_income_change": 1537.7903349770033, "relative_household_income_change": -3.5e-9, "population": 123746.60837519364 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1255.0, + "average_household_income_change_upper": -921.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000248", "constituency_name": "Constituency 248", - "x": 30, - "y": 7, + "x": 7, + "y": 6, "average_household_income_change": -1087.8785006624473, "relative_household_income_change": 1e-12, "population": 97600.39659601363 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1551.0, + "average_household_income_change_upper": -1140.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000249", "constituency_name": "Constituency 249", - "x": 0, - "y": 8, + "x": 8, + "y": 6, "average_household_income_change": -1345.7662153813862, "relative_household_income_change": 0.0, "population": 114313.09710587711 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1406.0, + "average_household_income_change_upper": 1911.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000250", "constituency_name": "Constituency 250", - "x": 1, - "y": 8, + "x": 9, + "y": 6, "average_household_income_change": 1658.6777073773708, "relative_household_income_change": -0.0, "population": 94609.60109811788 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -601.0, + "average_household_income_change_upper": -438.0, + "relative_household_income_change_lower": 0.0445, + "relative_household_income_change_upper": 0.0613, "constituency_code": "E14000000251", "constituency_name": "Constituency 251", - "x": 2, - "y": 8, + "x": 10, + "y": 6, "average_household_income_change": -519.6173843357359, "relative_household_income_change": 0.05288350383949525, "population": 101879.58740965034 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 49.0, + "average_household_income_change_upper": 74.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000252", "constituency_name": "Constituency 252", - "x": 3, - "y": 8, + "x": 11, + "y": 6, "average_household_income_change": 61.39724136454517, "relative_household_income_change": 0.0, "population": 122016.25936999946 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 167.0, + "average_household_income_change_upper": 234.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000253", "constituency_name": "Constituency 253", - "x": null, - "y": null, + "x": 12, + "y": 6, "average_household_income_change": 200.79508742853758, "relative_household_income_change": 1e-12, "population": 112382.16718067882 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 55.0, + "average_household_income_change_upper": 82.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000254", "constituency_name": "Constituency 254", - "x": 5, - "y": 8, + "x": 13, + "y": 6, "average_household_income_change": 68.46081134368464, "relative_household_income_change": 1e-12, "population": 79034.69952823792 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1211.0, + "average_household_income_change_upper": -889.0, + "relative_household_income_change_lower": 0.0578, + "relative_household_income_change_upper": 0.0793, "constituency_code": "E14000000255", "constituency_name": "Constituency 255", - "x": 6, - "y": 8, + "x": 14, + "y": 6, "average_household_income_change": -1049.8419544678975, "relative_household_income_change": 0.0685689670992683, "population": 84627.21548030981 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1596.0, + "average_household_income_change_upper": 2167.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000256", "constituency_name": "Constituency 256", - "x": 7, - "y": 8, + "x": 15, + "y": 6, "average_household_income_change": 1881.2593648861452, "relative_household_income_change": -3.5e-9, "population": 135723.15777362182 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -349.0, + "average_household_income_change_upper": -252.0, + "relative_household_income_change_lower": 0.3284, + "relative_household_income_change_upper": 0.4453, "constituency_code": "E14000000257", "constituency_name": "Constituency 257", - "x": 8, - "y": 8, + "x": 16, + "y": 6, "average_household_income_change": -300.59480412605757, "relative_household_income_change": 0.38686768114256065, "population": 140550.79135966688 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -348.0, + "average_household_income_change_upper": -251.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000258", "constituency_name": "Constituency 258", - "x": 9, - "y": 8, + "x": 17, + "y": 6, "average_household_income_change": -299.27228807240124, "relative_household_income_change": -3.5e-9, "population": 136940.02453417546 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2268.0, + "average_household_income_change_upper": -1670.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000259", "constituency_name": "Constituency 259", - "x": 10, - "y": 8, + "x": 18, + "y": 6, "average_household_income_change": -1968.8831338393782, "relative_household_income_change": 1e-12, "population": 116862.35781095125 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2232.0, + "average_household_income_change_upper": -1643.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000260", "constituency_name": "Constituency 260", - "x": null, - "y": null, + "x": 19, + "y": 6, "average_household_income_change": -1937.396708083625, "relative_household_income_change": -0.0, "population": 53050.95960456516 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 233.0, + "average_household_income_change_upper": 324.0, + "relative_household_income_change_lower": 0.695, + "relative_household_income_change_upper": 0.9414, "constituency_code": "E14000000261", "constituency_name": "Constituency 261", - "x": 12, - "y": 8, + "x": 20, + "y": 6, "average_household_income_change": 278.54418344988784, "relative_household_income_change": 0.8182049447786189, "population": 145996.30964651122 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2009.0, + "average_household_income_change_upper": -1479.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000262", "constituency_name": "Constituency 262", - "x": 13, - "y": 8, + "x": 21, + "y": 6, "average_household_income_change": -1743.60096454641, "relative_household_income_change": -3.5e-9, "population": 137665.3909221181 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 345.0, + "average_household_income_change_upper": 475.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000263", "constituency_name": "Constituency 263", - "x": 14, - "y": 8, + "x": 22, + "y": 6, "average_household_income_change": 410.3648552033592, "relative_household_income_change": 0.0, "population": 130733.25893769521 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1005.0, + "average_household_income_change_upper": -737.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000264", "constituency_name": "Constituency 264", - "x": 15, - "y": 8, + "x": 23, + "y": 6, "average_household_income_change": -870.6543863626357, "relative_household_income_change": 0.0, "population": 130659.0095046039 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 968.0, + "average_household_income_change_upper": 1318.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000265", "constituency_name": "Constituency 265", - "x": 16, - "y": 8, + "x": 24, + "y": 6, "average_household_income_change": 1143.162831284736, "relative_household_income_change": 0.0, "population": 107575.18022024026 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1220.0, + "average_household_income_change_upper": -896.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000266", "constituency_name": "Constituency 266", - "x": 17, - "y": 8, + "x": 25, + "y": 6, "average_household_income_change": -1057.9486970304836, "relative_household_income_change": -0.0, "population": 91442.6613340457 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1538.0, + "average_household_income_change_upper": 2090.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000267", "constituency_name": "Constituency 267", - "x": null, - "y": null, + "x": 26, + "y": 6, "average_household_income_change": 1814.0649747416485, "relative_household_income_change": 1e-12, "population": 65130.079693553234 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1748.0, + "average_household_income_change_upper": -1286.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000268", "constituency_name": "Constituency 268", - "x": 19, - "y": 8, + "x": 27, + "y": 6, "average_household_income_change": -1516.7977290124063, "relative_household_income_change": -0.0, "population": 55364.656447213056 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -342.0, + "average_household_income_change_upper": -247.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000269", "constituency_name": "Constituency 269", - "x": 20, - "y": 8, + "x": 28, + "y": 6, "average_household_income_change": -294.20877227662066, "relative_household_income_change": -0.0, "population": 61555.40574473531 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 588.0, + "average_household_income_change_upper": 803.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000270", - "constituency_name": "Constituency 270", - "x": 21, - "y": 8, + "constituency_name": "Constituency 270", + "x": 29, + "y": 6, "average_household_income_change": 695.5018348863717, "relative_household_income_change": -0.0, "population": 68812.25148927994 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -407.0, + "average_household_income_change_upper": -295.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000271", "constituency_name": "Constituency 271", - "x": 22, - "y": 8, + "x": 30, + "y": 6, "average_household_income_change": -351.1802166259006, "relative_household_income_change": -0.0, "population": 54027.65899145163 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1892.0, + "average_household_income_change_upper": -1392.0, + "relative_household_income_change_lower": 0.7118, + "relative_household_income_change_upper": 0.9641, "constituency_code": "E14000000272", "constituency_name": "Constituency 272", - "x": 23, - "y": 8, + "x": 31, + "y": 6, "average_household_income_change": -1642.2915725524824, "relative_household_income_change": 0.8379817398377877, "population": 88079.14001836749 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -229.0, + "average_household_income_change_upper": -163.0, + "relative_household_income_change_lower": 0.5259, + "relative_household_income_change_upper": 0.7125, "constituency_code": "E14000000273", "constituency_name": "Constituency 273", - "x": 24, - "y": 8, + "x": 32, + "y": 6, "average_household_income_change": -196.16611907612392, "relative_household_income_change": 0.6191859545022685, "population": 143901.09379379006 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1350.0, + "average_household_income_change_upper": 1835.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000274", "constituency_name": "Constituency 274", - "x": null, - "y": null, + "x": 33, + "y": 6, "average_household_income_change": 1592.5511593747626, "relative_household_income_change": 1e-12, "population": 56538.644750654064 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1059.0, + "average_household_income_change_upper": 1441.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000275", "constituency_name": "Constituency 275", - "x": 26, - "y": 8, + "x": 34, + "y": 6, "average_household_income_change": 1249.8308107022656, "relative_household_income_change": 0.0, "population": 82852.1573553915 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1935.0, + "average_household_income_change_upper": -1424.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000276", "constituency_name": "Constituency 276", - "x": 27, - "y": 8, + "x": 35, + "y": 6, "average_household_income_change": -1679.6035271853639, "relative_household_income_change": 1e-12, "population": 82624.31099995461 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 916.0, + "average_household_income_change_upper": 1247.0, + "relative_household_income_change_lower": 0.6047, + "relative_household_income_change_upper": 0.8192, "constituency_code": "E14000000277", "constituency_name": "Constituency 277", - "x": 28, - "y": 8, + "x": 36, + "y": 6, "average_household_income_change": 1081.625804541158, "relative_household_income_change": 0.7119632280929965, "population": 56373.70772158275 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1745.0, + "average_household_income_change_upper": -1283.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000278", "constituency_name": "Constituency 278", - "x": 29, - "y": 8, + "x": 37, + "y": 6, "average_household_income_change": -1514.008414001978, "relative_household_income_change": -3.5e-9, "population": 54038.13587168238 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1015.0, + "average_household_income_change_upper": -744.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000279", "constituency_name": "Constituency 279", - "x": 30, - "y": 8, + "x": 38, + "y": 6, "average_household_income_change": -879.656036805668, "relative_household_income_change": 0.0, "population": 66920.7276918206 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1653.0, + "average_household_income_change_upper": -1216.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000280", "constituency_name": "Constituency 280", - "x": 0, - "y": 9, + "x": 39, + "y": 6, "average_household_income_change": -1434.2729900398488, "relative_household_income_change": -3.5e-9, "population": 114525.67044788442 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 64.0, + "average_household_income_change_upper": 94.0, + "relative_household_income_change_lower": 0.7725, + "relative_household_income_change_upper": 1.0462, "constituency_code": "E14000000281", "constituency_name": "Constituency 281", - "x": null, - "y": null, + "x": 0, + "y": 7, "average_household_income_change": 78.84229063710268, "relative_household_income_change": 0.9093867952915566, "population": 71203.42519173468 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 7.0, + "average_household_income_change_upper": 18.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000282", "constituency_name": "Constituency 282", - "x": 2, - "y": 9, + "x": 1, + "y": 7, "average_household_income_change": 12.239268357744137, "relative_household_income_change": -0.0, "population": 57133.2670657449 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1015.0, + "average_household_income_change_upper": -744.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000283", "constituency_name": "Constituency 283", - "x": 3, - "y": 9, + "x": 2, + "y": 7, "average_household_income_change": -879.1966266472486, "relative_household_income_change": -3.5e-9, "population": 117950.37248663565 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 618.0, + "average_household_income_change_upper": 845.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000284", "constituency_name": "Constituency 284", - "x": 4, - "y": 9, + "x": 3, + "y": 7, "average_household_income_change": 731.6369198544824, "relative_household_income_change": 0.0, "population": 60677.670160915164 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 455.0, + "average_household_income_change_upper": 623.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000285", "constituency_name": "Constituency 285", - "x": 5, - "y": 9, + "x": 4, + "y": 7, "average_household_income_change": 538.8786664582988, "relative_household_income_change": 0.0, "population": 134931.71481241478 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -422.0, + "average_household_income_change_upper": -306.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000286", "constituency_name": "Constituency 286", - "x": 6, - "y": 9, + "x": 5, + "y": 7, "average_household_income_change": -363.61423705336284, "relative_household_income_change": -3.5e-9, "population": 59397.05353299759 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -51.0, + "average_household_income_change_upper": -32.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000287", "constituency_name": "Constituency 287", - "x": 7, - "y": 9, + "x": 6, + "y": 7, "average_household_income_change": -41.56094887664574, "relative_household_income_change": -0.0, "population": 101221.93183884669 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1890.0, + "average_household_income_change_upper": -1391.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000288", "constituency_name": "Constituency 288", - "x": null, - "y": null, + "x": 7, + "y": 7, "average_household_income_change": -1640.8394237733885, "relative_household_income_change": -0.0, "population": 85331.20565330614 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1363.0, + "average_household_income_change_upper": 1852.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000289", "constituency_name": "Constituency 289", - "x": 9, - "y": 9, + "x": 8, + "y": 7, "average_household_income_change": 1607.6675640232882, "relative_household_income_change": 0.0, "population": 54725.222443885046 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1663.0, + "average_household_income_change_upper": -1223.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000290", "constituency_name": "Constituency 290", - "x": 10, - "y": 9, + "x": 9, + "y": 7, "average_household_income_change": -1442.7349429318213, "relative_household_income_change": 1e-12, "population": 119504.45090787341 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1497.0, + "average_household_income_change_upper": -1100.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000291", "constituency_name": "Constituency 291", - "x": 11, - "y": 9, + "x": 10, + "y": 7, "average_household_income_change": -1298.611495104644, "relative_household_income_change": -0.0, "population": 124055.42495705158 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -422.0, + "average_household_income_change_upper": -306.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000292", "constituency_name": "Constituency 292", - "x": 12, - "y": 9, + "x": 11, + "y": 7, "average_household_income_change": -363.63670045492836, "relative_household_income_change": -0.0, "population": 80797.82277151337 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 763.0, + "average_household_income_change_upper": 1041.0, + "relative_household_income_change_lower": 0.5075, + "relative_household_income_change_upper": 0.6876, "constituency_code": "E14000000293", "constituency_name": "Constituency 293", - "x": 13, - "y": 9, + "x": 12, + "y": 7, "average_household_income_change": 902.118169666097, "relative_household_income_change": 0.5975509379823232, "population": 129073.02446683323 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -164.0, + "average_household_income_change_upper": -115.0, + "relative_household_income_change_lower": 0.0527, + "relative_household_income_change_upper": 0.0724, "constituency_code": "E14000000294", "constituency_name": "Constituency 294", - "x": 14, - "y": 9, + "x": 13, + "y": 7, "average_household_income_change": -139.25701863002172, "relative_household_income_change": 0.06255641858639482, "population": 57752.53085066069 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1341.0, + "average_household_income_change_upper": -985.0, + "relative_household_income_change_lower": 0.7445, + "relative_household_income_change_upper": 1.0083, "constituency_code": "E14000000295", "constituency_name": "Constituency 295", - "x": null, - "y": null, + "x": 14, + "y": 7, "average_household_income_change": -1163.432231007708, "relative_household_income_change": 0.8764210555336815, "population": 106333.06119563997 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1935.0, + "average_household_income_change_upper": -1424.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000296", "constituency_name": "Constituency 296", - "x": 16, - "y": 9, + "x": 15, + "y": 7, "average_household_income_change": -1679.9787950530365, "relative_household_income_change": 1e-12, "population": 75692.39275484561 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1342.0, + "average_household_income_change_upper": 1824.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000297", "constituency_name": "Constituency 297", - "x": 17, - "y": 9, + "x": 16, + "y": 7, "average_household_income_change": 1582.697326893069, "relative_household_income_change": 0.0, "population": 117975.52461036804 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -166.0, + "average_household_income_change_upper": -117.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000298", "constituency_name": "Constituency 298", - "x": 18, - "y": 9, + "x": 17, + "y": 7, "average_household_income_change": -141.68662686740774, "relative_household_income_change": 0.0, "population": 147122.08267887653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 208.0, + "average_household_income_change_upper": 289.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000299", "constituency_name": "Constituency 299", - "x": 19, - "y": 9, + "x": 18, + "y": 7, "average_household_income_change": 248.44524455147445, "relative_household_income_change": 0.0, "population": 57914.21355725573 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1654.0, + "average_household_income_change_upper": -1216.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000300", "constituency_name": "Constituency 300", - "x": 20, - "y": 9, + "x": 19, + "y": 7, "average_household_income_change": -1435.1194306235277, "relative_household_income_change": 1e-12, "population": 89667.12520548898 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 926.0, + "average_household_income_change_upper": 1261.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000301", "constituency_name": "Constituency 301", - "x": 21, - "y": 9, + "x": 20, + "y": 7, "average_household_income_change": 1093.4980272431533, "relative_household_income_change": 0.0, "population": 149640.69785386953 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 42.0, + "average_household_income_change_upper": 65.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000302", "constituency_name": "Constituency 302", - "x": null, - "y": null, + "x": 21, + "y": 7, "average_household_income_change": 53.08937001870451, "relative_household_income_change": -0.0, "population": 66230.07359490372 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -546.0, + "average_household_income_change_upper": -398.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000303", "constituency_name": "Constituency 303", - "x": 23, - "y": 9, + "x": 22, + "y": 7, "average_household_income_change": -471.8832314261454, "relative_household_income_change": -3.5e-9, "population": 57337.41263366653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1183.0, + "average_household_income_change_upper": -869.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000304", "constituency_name": "Constituency 304", - "x": 24, - "y": 9, + "x": 23, + "y": 7, "average_household_income_change": -1026.020840673814, "relative_household_income_change": 1e-12, "population": 142490.88938975122 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 514.0, + "average_household_income_change_upper": 704.0, + "relative_household_income_change_lower": 0.544, + "relative_household_income_change_upper": 0.737, "constituency_code": "E14000000305", "constituency_name": "Constituency 305", - "x": 25, - "y": 9, + "x": 24, + "y": 7, "average_household_income_change": 608.8165690306914, "relative_household_income_change": 0.6404988087018922, "population": 72487.89624498294 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -137.0, + "average_household_income_change_upper": -95.0, + "relative_household_income_change_lower": 0.3317, + "relative_household_income_change_upper": 0.4499, "constituency_code": "E14000000306", "constituency_name": "Constituency 306", - "x": 26, - "y": 9, + "x": 25, + "y": 7, "average_household_income_change": -115.80972292330483, "relative_household_income_change": 0.39081883301841813, "population": 77350.48896896599 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1833.0, + "average_household_income_change_upper": -1349.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000307", "constituency_name": "Constituency 307", - "x": 27, - "y": 9, + "x": 26, + "y": 7, "average_household_income_change": -1591.2097568655606, "relative_household_income_change": -0.0, "population": 130601.76627288382 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -237.0, + "average_household_income_change_upper": -169.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000308", "constituency_name": "Constituency 308", - "x": 28, - "y": 9, + "x": 27, + "y": 7, "average_household_income_change": -202.68597089062146, "relative_household_income_change": -3.5e-9, "population": 127803.58575356533 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1962.0, + "average_household_income_change_upper": -1444.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000309", "constituency_name": "Constituency 309", - "x": null, - "y": null, + "x": 28, + "y": 7, "average_household_income_change": -1703.293029455986, "relative_household_income_change": 0.0, "population": 96967.75627127332 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -960.0, + "average_household_income_change_upper": -703.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000310", "constituency_name": "Constituency 310", - "x": 30, - "y": 9, + "x": 29, + "y": 7, "average_household_income_change": -831.6689332469039, "relative_household_income_change": -3.5e-9, "population": 115309.39370340896 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1586.0, + "average_household_income_change_upper": 2154.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000311", "constituency_name": "Constituency 311", - "x": 0, - "y": 10, + "x": 30, + "y": 7, "average_household_income_change": 1870.4055118959807, "relative_household_income_change": 0.0, "population": 83882.90139684646 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 414.0, + "average_household_income_change_upper": 569.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000312", "constituency_name": "Constituency 312", - "x": 1, - "y": 10, + "x": 31, + "y": 7, "average_household_income_change": 491.49040529432983, "relative_household_income_change": -0.0, "population": 80790.58693080283 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 316.0, + "average_household_income_change_upper": 435.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000313", "constituency_name": "Constituency 313", - "x": 2, - "y": 10, + "x": 32, + "y": 7, "average_household_income_change": 375.3827408162542, "relative_household_income_change": 1e-12, "population": 84326.10208188766 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 25.0, + "average_household_income_change_upper": 41.0, + "relative_household_income_change_lower": 0.4593, + "relative_household_income_change_upper": 0.6225, "constituency_code": "E14000000314", "constituency_name": "Constituency 314", - "x": 3, - "y": 10, + "x": 33, + "y": 7, "average_household_income_change": 33.000174958145664, "relative_household_income_change": 0.5409158451119593, "population": 133101.32286826705 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1543.0, + "average_household_income_change_upper": -1134.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000315", "constituency_name": "Constituency 315", - "x": 4, - "y": 10, + "x": 34, + "y": 7, "average_household_income_change": -1338.6782648550195, "relative_household_income_change": 1e-12, "population": 135673.8907982766 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -746.0, + "average_household_income_change_upper": -545.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000316", "constituency_name": "Constituency 316", - "x": null, - "y": null, + "x": 35, + "y": 7, "average_household_income_change": -645.4749752199311, "relative_household_income_change": 0.0, "population": 53154.68071281595 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 740.0, + "average_household_income_change_upper": 1010.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000317", "constituency_name": "Constituency 317", - "x": 6, - "y": 10, + "x": 36, + "y": 7, "average_household_income_change": 875.2196826351822, "relative_household_income_change": -0.0, "population": 54663.23250214491 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2196.0, + "average_household_income_change_upper": -1617.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000318", "constituency_name": "Constituency 318", - "x": 7, - "y": 10, + "x": 37, + "y": 7, "average_household_income_change": -1906.3685553236708, "relative_household_income_change": -0.0, "population": 62216.10372183205 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 222.0, + "average_household_income_change_upper": 309.0, + "relative_household_income_change_lower": 0.0599, + "relative_household_income_change_upper": 0.0821, "constituency_code": "E14000000319", "constituency_name": "Constituency 319", - "x": 8, - "y": 10, + "x": 38, + "y": 7, "average_household_income_change": 265.67730367256326, "relative_household_income_change": 0.07095713112290669, "population": 123272.98162748596 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 810.0, + "average_household_income_change_upper": 1105.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000320", "constituency_name": "Constituency 320", - "x": 9, - "y": 10, + "x": 39, + "y": 7, "average_household_income_change": 957.6221647457519, "relative_household_income_change": -3.5e-9, "population": 117320.59667338144 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 755.0, + "average_household_income_change_upper": 1030.0, + "relative_household_income_change_lower": 0.0127, + "relative_household_income_change_upper": 0.0182, "constituency_code": "E14000000321", "constituency_name": "Constituency 321", - "x": 10, - "y": 10, + "x": 0, + "y": 8, "average_household_income_change": 892.5971352867052, "relative_household_income_change": 0.01547062226303364, "population": 64382.017243272196 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -106.0, + "average_household_income_change_upper": -73.0, + "relative_household_income_change_lower": 0.5448, + "relative_household_income_change_upper": 0.7382, "constituency_code": "E14000000322", "constituency_name": "Constituency 322", - "x": 11, - "y": 10, + "x": 1, + "y": 8, "average_household_income_change": -89.5120196189252, "relative_household_income_change": 0.6415075542058354, "population": 125144.8692163065 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1536.0, + "average_household_income_change_upper": 2086.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000323", "constituency_name": "Constituency 323", - "x": null, - "y": null, + "x": 2, + "y": 8, "average_household_income_change": 1811.2694791851518, "relative_household_income_change": -3.5e-9, "population": 65712.70150761414 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1269.0, + "average_household_income_change_upper": -932.0, + "relative_household_income_change_lower": 0.3553, + "relative_household_income_change_upper": 0.4817, "constituency_code": "E14000000324", "constituency_name": "Constituency 324", - "x": 13, - "y": 10, + "x": 3, + "y": 8, "average_household_income_change": -1100.7471361008027, "relative_household_income_change": 0.4185108779894693, "population": 114836.3220960143 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1294.0, + "average_household_income_change_upper": -951.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000325", "constituency_name": "Constituency 325", - "x": 14, - "y": 10, + "x": 4, + "y": 8, "average_household_income_change": -1122.6043621770434, "relative_household_income_change": 1e-12, "population": 55237.5686532306 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1593.0, + "average_household_income_change_upper": -1172.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000326", "constituency_name": "Constituency 326", - "x": 15, - "y": 10, + "x": 5, + "y": 8, "average_household_income_change": -1382.4413984731443, "relative_household_income_change": -0.0, "population": 94786.54853619754 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -722.0, + "average_household_income_change_upper": -528.0, + "relative_household_income_change_lower": 0.6869, + "relative_household_income_change_upper": 0.9304, "constituency_code": "E14000000327", "constituency_name": "Constituency 327", - "x": 16, - "y": 10, + "x": 6, + "y": 8, "average_household_income_change": -624.9672122114114, "relative_household_income_change": 0.8086293754051761, "population": 91180.08167511584 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1766.0, + "average_household_income_change_upper": -1299.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000328", "constituency_name": "Constituency 328", - "x": 17, - "y": 10, + "x": 7, + "y": 8, "average_household_income_change": -1532.5080712305694, "relative_household_income_change": 1e-12, "population": 74992.84884821136 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1065.0, + "average_household_income_change_upper": 1449.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000329", "constituency_name": "Constituency 329", - "x": 18, - "y": 10, + "x": 8, + "y": 8, "average_household_income_change": 1256.8611941470808, "relative_household_income_change": -0.0, "population": 73264.68467329515 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1238.0, + "average_household_income_change_upper": 1683.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000330", "constituency_name": "Constituency 330", - "x": null, - "y": null, + "x": 9, + "y": 8, "average_household_income_change": 1460.216582001018, "relative_household_income_change": -0.0, "population": 120762.21259691025 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 958.0, + "average_household_income_change_upper": 1304.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000331", "constituency_name": "Constituency 331", - "x": 20, - "y": 10, + "x": 10, + "y": 8, "average_household_income_change": 1131.1627205329526, "relative_household_income_change": -3.5e-9, "population": 124414.09849910664 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1096.0, + "average_household_income_change_upper": -804.0, + "relative_household_income_change_lower": 0.2551, + "relative_household_income_change_upper": 0.3462, "constituency_code": "E14000000332", "constituency_name": "Constituency 332", - "x": 21, - "y": 10, + "x": 11, + "y": 8, "average_household_income_change": -950.333632645132, "relative_household_income_change": 0.30068912666377334, "population": 102386.62156113084 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -765.0, + "average_household_income_change_upper": -559.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000333", "constituency_name": "Constituency 333", - "x": 22, - "y": 10, + "x": 12, + "y": 8, "average_household_income_change": -662.1771673166976, "relative_household_income_change": -3.5e-9, "population": 99217.79434560373 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -360.0, + "average_household_income_change_upper": -260.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000334", "constituency_name": "Constituency 334", - "x": 23, - "y": 10, + "x": 13, + "y": 8, "average_household_income_change": -310.1144178009183, "relative_household_income_change": 0.0, "population": 82642.85198374328 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -117.0, + "average_household_income_change_upper": -80.0, + "relative_household_income_change_lower": 0.2093, + "relative_household_income_change_upper": 0.2843, "constituency_code": "E14000000335", "constituency_name": "Constituency 335", - "x": 24, - "y": 10, + "x": 14, + "y": 8, "average_household_income_change": -98.61823883872577, "relative_household_income_change": 0.24680610402164405, "population": 101928.52381666923 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1527.0, + "average_household_income_change_upper": 2074.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000336", "constituency_name": "Constituency 336", - "x": 25, - "y": 10, + "x": 15, + "y": 8, "average_household_income_change": 1800.075825253157, "relative_household_income_change": 0.0, "population": 106604.47201418565 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1182.0, + "average_household_income_change_upper": -867.0, + "relative_household_income_change_lower": 0.4486, + "relative_household_income_change_upper": 0.608, "constituency_code": "E14000000337", "constituency_name": "Constituency 337", - "x": null, - "y": null, + "x": 16, + "y": 8, "average_household_income_change": -1024.5596852965332, "relative_household_income_change": 0.5282743216274547, "population": 84831.59483429573 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1797.0, + "average_household_income_change_upper": -1322.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000338", "constituency_name": "Constituency 338", - "x": 27, - "y": 10, + "x": 17, + "y": 8, "average_household_income_change": -1559.9200409332993, "relative_household_income_change": 0.0, "population": 132190.56010856107 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -374.0, + "average_household_income_change_upper": -271.0, + "relative_household_income_change_lower": 0.1911, + "relative_household_income_change_upper": 0.2595, "constituency_code": "E14000000339", "constituency_name": "Constituency 339", - "x": 28, - "y": 10, + "x": 18, + "y": 8, "average_household_income_change": -322.5458070710423, "relative_household_income_change": 0.22529688748048748, "population": 106479.0884301016 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 826.0, + "average_household_income_change_upper": 1126.0, + "relative_household_income_change_lower": 0.784, + "relative_household_income_change_upper": 1.0617, "constituency_code": "E14000000340", "constituency_name": "Constituency 340", - "x": 29, - "y": 10, + "x": 19, + "y": 8, "average_household_income_change": 975.9731193891134, "relative_household_income_change": 0.9228591687698418, "population": 147412.05136602212 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1036.0, + "average_household_income_change_upper": 1410.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000341", "constituency_name": "Constituency 341", - "x": 30, - "y": 10, + "x": 20, + "y": 8, "average_household_income_change": 1222.6290457869818, "relative_household_income_change": 0.0, "population": 144225.48837876614 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -365.0, + "average_household_income_change_upper": -264.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000342", "constituency_name": "Constituency 342", - "x": 0, - "y": 11, + "x": 21, + "y": 8, "average_household_income_change": -314.19552200388875, "relative_household_income_change": 1e-12, "population": 111129.0169869452 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1134.0, + "average_household_income_change_upper": 1542.0, + "relative_household_income_change_lower": 0.515, + "relative_household_income_change_upper": 0.6979, "constituency_code": "E14000000343", "constituency_name": "Constituency 343", - "x": 1, - "y": 11, + "x": 22, + "y": 8, "average_household_income_change": 1337.6914447481315, "relative_household_income_change": 0.6064418096250256, "population": 72777.5551270252 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -579.0, + "average_household_income_change_upper": -422.0, + "relative_household_income_change_lower": 0.0211, + "relative_household_income_change_upper": 0.0297, "constituency_code": "E14000000344", "constituency_name": "Constituency 344", - "x": null, - "y": null, + "x": 23, + "y": 8, "average_household_income_change": -500.28648215289894, "relative_household_income_change": 0.02539666980792299, "population": 140300.06720143737 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1038.0, + "average_household_income_change_upper": -761.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000345", "constituency_name": "Constituency 345", - "x": 3, - "y": 11, + "x": 24, + "y": 8, "average_household_income_change": -899.8299632125404, "relative_household_income_change": -3.5e-9, "population": 118550.12291309344 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -952.0, + "average_household_income_change_upper": -697.0, + "relative_household_income_change_lower": 0.0479, + "relative_household_income_change_upper": 0.0659, "constituency_code": "E14000000346", "constituency_name": "Constituency 346", - "x": 4, - "y": 11, + "x": 25, + "y": 8, "average_household_income_change": -824.48101449791, "relative_household_income_change": 0.05691560918933902, "population": 84746.18474953297 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1153.0, + "average_household_income_change_upper": 1568.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000347", "constituency_name": "Constituency 347", - "x": 5, - "y": 11, + "x": 26, + "y": 8, "average_household_income_change": 1360.4905042331795, "relative_household_income_change": 1e-12, "population": 115209.82974713409 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1505.0, + "average_household_income_change_upper": 2045.0, + "relative_household_income_change_lower": 0.0974, + "relative_household_income_change_upper": 0.1329, "constituency_code": "E14000000348", "constituency_name": "Constituency 348", - "x": 6, - "y": 11, + "x": 27, + "y": 8, "average_household_income_change": 1775.1536699727137, "relative_household_income_change": 0.11514487999565826, "population": 80112.3937259131 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1448.0, + "average_household_income_change_upper": 1967.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000349", "constituency_name": "Constituency 349", - "x": 7, - "y": 11, + "x": 28, + "y": 8, "average_household_income_change": 1707.8194320756604, "relative_household_income_change": 1e-12, "population": 76833.07357783025 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1271.0, + "average_household_income_change_upper": 1728.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000350", "constituency_name": "Constituency 350", - "x": 8, - "y": 11, + "x": 29, + "y": 8, "average_household_income_change": 1499.4873663114822, "relative_household_income_change": 1e-12, "population": 102963.34481014374 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -976.0, + "average_household_income_change_upper": -715.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000351", "constituency_name": "Constituency 351", - "x": null, - "y": null, + "x": 30, + "y": 8, "average_household_income_change": -845.488142728954, "relative_household_income_change": 0.0, "population": 77060.88122496438 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 758.0, + "average_household_income_change_upper": 1034.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000352", "constituency_name": "Constituency 352", - "x": 10, - "y": 11, + "x": 31, + "y": 8, "average_household_income_change": 895.7933970570076, "relative_household_income_change": 1e-12, "population": 121186.05852623229 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 367.0, + "average_household_income_change_upper": 505.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000353", "constituency_name": "Constituency 353", - "x": 11, - "y": 11, + "x": 32, + "y": 8, "average_household_income_change": 436.30185633340534, "relative_household_income_change": -3.5e-9, "population": 90359.39372869075 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1299.0, + "average_household_income_change_upper": 1765.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000354", "constituency_name": "Constituency 354", - "x": 12, - "y": 11, + "x": 33, + "y": 8, "average_household_income_change": 1531.899304402959, "relative_household_income_change": -3.5e-9, "population": 125773.89335858136 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1612.0, + "average_household_income_change_upper": 2189.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000355", "constituency_name": "Constituency 355", - "x": 13, - "y": 11, + "x": 34, + "y": 8, "average_household_income_change": 1900.3636110963519, "relative_household_income_change": -3.5e-9, "population": 86402.77703380983 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 839.0, + "average_household_income_change_upper": 1143.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000356", "constituency_name": "Constituency 356", - "x": 14, - "y": 11, + "x": 35, + "y": 8, "average_household_income_change": 990.6809907292611, "relative_household_income_change": 1e-12, "population": 133926.5278974796 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -122.0, + "average_household_income_change_upper": -84.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000357", "constituency_name": "Constituency 357", - "x": 15, - "y": 11, + "x": 36, + "y": 8, "average_household_income_change": -102.81392002473177, "relative_household_income_change": 1e-12, "population": 91672.73914310064 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1198.0, + "average_household_income_change_upper": 1629.0, + "relative_household_income_change_lower": 0.1002, + "relative_household_income_change_upper": 0.1366, "constituency_code": "E14000000358", "constituency_name": "Constituency 358", - "x": null, - "y": null, + "x": 37, + "y": 8, "average_household_income_change": 1413.2362731585495, "relative_household_income_change": 0.11842765300769598, "population": 131869.28448096052 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1759.0, + "average_household_income_change_upper": -1294.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000359", "constituency_name": "Constituency 359", - "x": 17, - "y": 11, + "x": 38, + "y": 8, "average_household_income_change": -1526.3658607536765, "relative_household_income_change": 1e-12, "population": 76359.1774732786 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1191.0, + "average_household_income_change_upper": -874.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000360", "constituency_name": "Constituency 360", - "x": 18, - "y": 11, + "x": 39, + "y": 8, "average_household_income_change": -1032.2988351211718, "relative_household_income_change": -3.5e-9, "population": 81960.88765276896 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 767.0, + "average_household_income_change_upper": 1046.0, + "relative_household_income_change_lower": 0.4261, + "relative_household_income_change_upper": 0.5775, "constituency_code": "E14000000361", "constituency_name": "Constituency 361", - "x": 19, - "y": 11, + "x": 0, + "y": 9, "average_household_income_change": 906.6356976421935, "relative_household_income_change": 0.5017789670349119, "population": 57719.93054066484 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1925.0, + "average_household_income_change_upper": -1417.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000362", "constituency_name": "Constituency 362", - "x": 20, - "y": 11, + "x": 1, + "y": 9, "average_household_income_change": -1671.2986473147962, "relative_household_income_change": -0.0, "population": 113415.38429893683 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 91.0, + "average_household_income_change_upper": 131.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000363", "constituency_name": "Constituency 363", - "x": 21, - "y": 11, + "x": 2, + "y": 9, "average_household_income_change": 111.27087975723614, "relative_household_income_change": 0.0, "population": 68079.6212173605 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -903.0, + "average_household_income_change_upper": -661.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000364", "constituency_name": "Constituency 364", - "x": 22, - "y": 11, + "x": 3, + "y": 9, "average_household_income_change": -781.90725602331, "relative_household_income_change": -0.0, "population": 67864.18906912404 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 256.0, + "average_household_income_change_upper": 354.0, + "relative_household_income_change_lower": 0.3957, + "relative_household_income_change_upper": 0.5365, "constituency_code": "E14000000365", "constituency_name": "Constituency 365", - "x": null, - "y": null, + "x": 4, + "y": 9, "average_household_income_change": 305.0824783887256, "relative_household_income_change": 0.4660933581476817, "population": 61334.79792201664 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 612.0, + "average_household_income_change_upper": 837.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000366", "constituency_name": "Constituency 366", - "x": 24, - "y": 11, + "x": 5, + "y": 9, "average_household_income_change": 724.4525151520427, "relative_household_income_change": 1e-12, "population": 106024.91759136216 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1961.0, + "average_household_income_change_upper": -1444.0, + "relative_household_income_change_lower": 0.0935, + "relative_household_income_change_upper": 0.1276, "constituency_code": "E14000000367", "constituency_name": "Constituency 367", - "x": 25, - "y": 11, + "x": 6, + "y": 9, "average_household_income_change": -1702.5549476447566, "relative_household_income_change": 0.11056314456087024, "population": 81926.19788223934 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2225.0, + "average_household_income_change_upper": -1638.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000368", "constituency_name": "Constituency 368", - "x": 26, - "y": 11, + "x": 7, + "y": 9, "average_household_income_change": -1931.3909280413527, "relative_household_income_change": 1e-12, "population": 71770.85701884079 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 670.0, + "average_household_income_change_upper": 914.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000369", "constituency_name": "Constituency 369", - "x": 27, - "y": 11, + "x": 8, + "y": 9, "average_household_income_change": 792.0308313732289, "relative_household_income_change": 1e-12, "population": 50440.10731257584 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 359.0, + "average_household_income_change_upper": 493.0, + "relative_household_income_change_lower": 0.601, + "relative_household_income_change_upper": 0.8142, "constituency_code": "E14000000370", "constituency_name": "Constituency 370", - "x": 28, - "y": 11, + "x": 9, + "y": 9, "average_household_income_change": 425.9231978760969, "relative_household_income_change": 0.7075709754638798, "population": 112090.1619356483 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2090.0, + "average_household_income_change_upper": -1539.0, + "relative_household_income_change_lower": 0.6873, + "relative_household_income_change_upper": 0.931, "constituency_code": "E14000000371", "constituency_name": "Constituency 371", - "x": 29, - "y": 11, + "x": 10, + "y": 9, "average_household_income_change": -1814.3519191458379, "relative_household_income_change": 0.8091499706226665, "population": 52356.510651433506 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 939.0, + "average_household_income_change_upper": 1279.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000372", "constituency_name": "Constituency 372", - "x": null, - "y": null, + "x": 11, + "y": 9, "average_household_income_change": 1109.08621756159, "relative_household_income_change": -0.0, "population": 83600.63778977693 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 13.0, + "average_household_income_change_upper": 26.0, + "relative_household_income_change_lower": 0.437, + "relative_household_income_change_upper": 0.5923, "constituency_code": "E14000000373", "constituency_name": "Constituency 373", - "x": 0, - "y": 12, + "x": 12, + "y": 9, "average_household_income_change": 19.22110165496216, "relative_household_income_change": 0.5146548251212197, "population": 66507.908891974 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -866.0, + "average_household_income_change_upper": -634.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000374", "constituency_name": "Constituency 374", - "x": 1, - "y": 12, + "x": 13, + "y": 9, "average_household_income_change": -749.5772000344579, "relative_household_income_change": -3.5e-9, "population": 146792.99486028682 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -905.0, + "average_household_income_change_upper": -663.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000375", "constituency_name": "Constituency 375", - "x": 2, - "y": 12, + "x": 14, + "y": 9, "average_household_income_change": -783.8395562619974, "relative_household_income_change": 1e-12, "population": 65422.167957346835 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1671.0, + "average_household_income_change_upper": -1229.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000376", "constituency_name": "Constituency 376", - "x": 3, - "y": 12, + "x": 15, + "y": 9, "average_household_income_change": -1450.1221650989824, "relative_household_income_change": 0.0, "population": 85866.87293099564 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -746.0, + "average_household_income_change_upper": -545.0, + "relative_household_income_change_lower": 0.4225, + "relative_household_income_change_upper": 0.5727, "constituency_code": "E14000000377", "constituency_name": "Constituency 377", - "x": 4, - "y": 12, + "x": 16, + "y": 9, "average_household_income_change": -645.798670669981, "relative_household_income_change": 0.4976229122040582, "population": 122889.59326278509 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1386.0, + "average_household_income_change_upper": 1884.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000378", "constituency_name": "Constituency 378", - "x": 5, - "y": 12, + "x": 17, + "y": 9, "average_household_income_change": 1635.1724174774276, "relative_household_income_change": 0.0, "population": 140388.28343393683 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1525.0, + "average_household_income_change_upper": -1121.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000379", "constituency_name": "Constituency 379", - "x": null, - "y": null, + "x": 18, + "y": 9, "average_household_income_change": -1322.91414940455, "relative_household_income_change": 1e-12, "population": 130180.15071380371 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 544.0, + "average_household_income_change_upper": 744.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000380", "constituency_name": "Constituency 380", - "x": 7, - "y": 12, + "x": 19, + "y": 9, "average_household_income_change": 643.6955982549462, "relative_household_income_change": 1e-12, "population": 141937.50754987824 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2045.0, + "average_household_income_change_upper": -1505.0, + "relative_household_income_change_lower": 0.3687, + "relative_household_income_change_upper": 0.4999, "constituency_code": "E14000000381", "constituency_name": "Constituency 381", - "x": 8, - "y": 12, + "x": 20, + "y": 9, "average_household_income_change": -1775.2096638418507, "relative_household_income_change": 0.43429070796214286, "population": 145004.63125557202 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1488.0, + "average_household_income_change_upper": 2021.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000382", "constituency_name": "Constituency 382", - "x": 9, - "y": 12, + "x": 21, + "y": 9, "average_household_income_change": 1754.3529428875963, "relative_household_income_change": 1e-12, "population": 82461.35902260432 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 961.0, + "average_household_income_change_upper": 1308.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000383", "constituency_name": "Constituency 383", - "x": 10, - "y": 12, + "x": 22, + "y": 9, "average_household_income_change": 1134.450520364727, "relative_household_income_change": 1e-12, "population": 146435.07039696595 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2229.0, + "average_household_income_change_upper": -1641.0, + "relative_household_income_change_lower": 0.2742, + "relative_household_income_change_upper": 0.3721, "constituency_code": "E14000000384", "constituency_name": "Constituency 384", - "x": 11, - "y": 12, + "x": 23, + "y": 9, "average_household_income_change": -1935.1463493486115, "relative_household_income_change": 0.32313335925070485, "population": 58004.102496374035 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -244.0, + "average_household_income_change_upper": -174.0, + "relative_household_income_change_lower": 0.5254, + "relative_household_income_change_upper": 0.7119, "constituency_code": "E14000000385", "constituency_name": "Constituency 385", - "x": 12, - "y": 12, + "x": 24, + "y": 9, "average_household_income_change": -209.05028104926782, "relative_household_income_change": 0.6186153260113829, "population": 117034.62816551719 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1436.0, + "average_household_income_change_upper": 1952.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000386", "constituency_name": "Constituency 386", - "x": null, - "y": null, + "x": 25, + "y": 9, "average_household_income_change": 1694.0160478141113, "relative_household_income_change": -0.0, "population": 89858.43775105188 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1019.0, + "average_household_income_change_upper": 1387.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000387", "constituency_name": "Constituency 387", - "x": 14, - "y": 12, + "x": 26, + "y": 9, "average_household_income_change": 1203.3637271579084, "relative_household_income_change": -3.5e-9, "population": 147699.35190383965 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1201.0, + "average_household_income_change_upper": -882.0, + "relative_household_income_change_lower": 0.6593, + "relative_household_income_change_upper": 0.8931, "constituency_code": "E14000000388", "constituency_name": "Constituency 388", - "x": 15, - "y": 12, + "x": 27, + "y": 9, "average_household_income_change": -1041.2816219126073, "relative_household_income_change": 0.7761888702877715, "population": 140566.11583849424 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1665.0, + "average_household_income_change_upper": 2262.0, + "relative_household_income_change_lower": 0.1343, + "relative_household_income_change_upper": 0.1828, "constituency_code": "E14000000389", "constituency_name": "Constituency 389", - "x": 16, - "y": 12, + "x": 28, + "y": 9, "average_household_income_change": 1963.4883502536732, "relative_household_income_change": 0.15856590673397397, "population": 99208.27114640783 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -626.0, + "average_household_income_change_upper": -457.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000390", "constituency_name": "Constituency 390", - "x": 17, - "y": 12, + "x": 29, + "y": 9, "average_household_income_change": -541.6746768853842, "relative_household_income_change": -0.0, "population": 146739.93179938247 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 330.0, + "average_household_income_change_upper": 455.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000391", "constituency_name": "Constituency 391", - "x": 18, - "y": 12, + "x": 30, + "y": 9, "average_household_income_change": 392.3345088407983, "relative_household_income_change": -3.5e-9, "population": 110310.79494795081 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1607.0, + "average_household_income_change_upper": -1182.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000392", "constituency_name": "Constituency 392", - "x": 19, - "y": 12, + "x": 31, + "y": 9, "average_household_income_change": -1394.3824785730135, "relative_household_income_change": 0.0, "population": 91123.9105954635 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1600.0, + "average_household_income_change_upper": 2173.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000393", "constituency_name": "Constituency 393", - "x": null, - "y": null, + "x": 32, + "y": 9, "average_household_income_change": 1886.147031653585, "relative_household_income_change": 1e-12, "population": 127053.70056596707 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -883.0, + "average_household_income_change_upper": -646.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000394", "constituency_name": "Constituency 394", - "x": 21, - "y": 12, + "x": 33, + "y": 9, "average_household_income_change": -764.3913703477714, "relative_household_income_change": 0.0, "population": 74690.3431446662 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -478.0, + "average_household_income_change_upper": -347.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000395", "constituency_name": "Constituency 395", - "x": 22, - "y": 12, + "x": 34, + "y": 9, "average_household_income_change": -412.86388601931725, "relative_household_income_change": -0.0, "population": 91152.69472270005 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -606.0, + "average_household_income_change_upper": -442.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000396", "constituency_name": "Constituency 396", - "x": 23, - "y": 12, + "x": 35, + "y": 9, "average_household_income_change": -523.5367854396005, "relative_household_income_change": -0.0, "population": 96083.32104944291 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -508.0, + "average_household_income_change_upper": -369.0, + "relative_household_income_change_lower": 0.3128, + "relative_household_income_change_upper": 0.4243, "constituency_code": "E14000000397", "constituency_name": "Constituency 397", - "x": 24, - "y": 12, + "x": 36, + "y": 9, "average_household_income_change": -438.4916791503861, "relative_household_income_change": 0.36857333601514064, "population": 65334.83510074024 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1716.0, + "average_household_income_change_upper": -1262.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000398", "constituency_name": "Constituency 398", - "x": 25, - "y": 12, + "x": 37, + "y": 9, "average_household_income_change": -1489.3255914403437, "relative_household_income_change": -0.0, "population": 75879.3948917247 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1085.0, + "average_household_income_change_upper": 1476.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000399", "constituency_name": "Constituency 399", - "x": 26, - "y": 12, + "x": 38, + "y": 9, "average_household_income_change": 1280.5129456302825, "relative_household_income_change": 0.0, "population": 140421.26314416766 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 709.0, + "average_household_income_change_upper": 967.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000400", "constituency_name": "Constituency 400", - "x": null, - "y": null, + "x": 39, + "y": 9, "average_household_income_change": 838.1710243074423, "relative_household_income_change": 1e-12, "population": 102164.8608901718 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2057.0, + "average_household_income_change_upper": -1514.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000401", "constituency_name": "Constituency 401", - "x": 28, - "y": 12, + "x": 0, + "y": 10, "average_household_income_change": -1785.5002054920485, "relative_household_income_change": -0.0, "population": 87580.2271317752 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -796.0, + "average_household_income_change_upper": -582.0, + "relative_household_income_change_lower": 0.5727, + "relative_household_income_change_upper": 0.7758, "constituency_code": "E14000000402", "constituency_name": "Constituency 402", - "x": 29, - "y": 12, + "x": 1, + "y": 10, "average_household_income_change": -689.4004480676439, "relative_household_income_change": 0.6742375088852286, "population": 81438.53883171464 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -456.0, + "average_household_income_change_upper": -331.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000403", "constituency_name": "Constituency 403", - "x": 30, - "y": 12, + "x": 2, + "y": 10, "average_household_income_change": -393.4628128033797, "relative_household_income_change": -0.0, "population": 75039.98922314639 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1327.0, + "average_household_income_change_upper": 1803.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000404", "constituency_name": "Constituency 404", - "x": 0, - "y": 13, + "x": 3, + "y": 10, "average_household_income_change": 1565.0837804033047, "relative_household_income_change": 0.0, "population": 142227.87444765354 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -392.0, + "average_household_income_change_upper": -284.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000405", "constituency_name": "Constituency 405", - "x": 1, - "y": 13, + "x": 4, + "y": 10, "average_household_income_change": -337.8714605234876, "relative_household_income_change": -3.5e-9, "population": 77203.57983346927 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1217.0, + "average_household_income_change_upper": -894.0, + "relative_household_income_change_lower": 0.3462, + "relative_household_income_change_upper": 0.4695, "constituency_code": "E14000000406", "constituency_name": "Constituency 406", - "x": 2, - "y": 13, + "x": 5, + "y": 10, "average_household_income_change": -1055.3789197118797, "relative_household_income_change": 0.4078556824436944, "population": 136422.76200474886 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 705.0, + "average_household_income_change_upper": 963.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000407", "constituency_name": "Constituency 407", - "x": null, - "y": null, + "x": 6, + "y": 10, "average_household_income_change": 834.0527556961325, "relative_household_income_change": -3.5e-9, "population": 97354.9639783863 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 110.0, + "average_household_income_change_upper": 158.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000408", "constituency_name": "Constituency 408", - "x": 4, - "y": 13, + "x": 7, + "y": 10, "average_household_income_change": 134.09988432838736, "relative_household_income_change": -3.5e-9, "population": 119310.48812370429 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 600.0, + "average_household_income_change_upper": 820.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000409", "constituency_name": "Constituency 409", - "x": 5, - "y": 13, + "x": 8, + "y": 10, "average_household_income_change": 710.0191188423665, "relative_household_income_change": -3.5e-9, "population": 104900.49608990214 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1997.0, + "average_household_income_change_upper": -1470.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000410", "constituency_name": "Constituency 410", - "x": 6, - "y": 13, + "x": 9, + "y": 10, "average_household_income_change": -1733.3123895866431, "relative_household_income_change": -3.5e-9, "population": 72416.44538586303 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1126.0, + "average_household_income_change_upper": 1532.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000411", "constituency_name": "Constituency 411", - "x": 7, - "y": 13, + "x": 10, + "y": 10, "average_household_income_change": 1328.957189349265, "relative_household_income_change": 1e-12, "population": 55101.62863024679 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 732.0, + "average_household_income_change_upper": 998.0, + "relative_household_income_change_lower": 0.2194, + "relative_household_income_change_upper": 0.2979, "constituency_code": "E14000000412", "constituency_name": "Constituency 412", - "x": 8, - "y": 13, + "x": 11, + "y": 10, "average_household_income_change": 864.9534307001913, "relative_household_income_change": 0.25864922967497583, "population": 82769.6707088191 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1171.0, + "average_household_income_change_upper": -860.0, + "relative_household_income_change_lower": 0.4727, + "relative_household_income_change_upper": 0.6406, "constituency_code": "E14000000413", "constituency_name": "Constituency 413", - "x": 9, - "y": 13, + "x": 12, + "y": 10, "average_household_income_change": -1015.563802054348, "relative_household_income_change": 0.5566808097245476, "population": 115698.84677273533 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1668.0, + "average_household_income_change_upper": 2264.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000414", "constituency_name": "Constituency 414", - "x": null, - "y": null, + "x": 13, + "y": 10, "average_household_income_change": 1966.0549435935516, "relative_household_income_change": -3.5e-9, "population": 63072.63158724338 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1981.0, + "average_household_income_change_upper": -1458.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000415", "constituency_name": "Constituency 415", - "x": 11, - "y": 13, + "x": 14, + "y": 10, "average_household_income_change": -1719.4482496747435, "relative_household_income_change": 1e-12, "population": 122400.05252854996 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -736.0, + "average_household_income_change_upper": -538.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000416", "constituency_name": "Constituency 416", - "x": 12, - "y": 13, + "x": 15, + "y": 10, "average_household_income_change": -637.0025407932425, "relative_household_income_change": -0.0, "population": 117953.03763761363 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -666.0, + "average_household_income_change_upper": -486.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000417", - "constituency_name": "Constituency 417", - "x": 13, - "y": 13, + "constituency_name": "Constituency 417", + "x": 16, + "y": 10, "average_household_income_change": -576.1912016158312, "relative_household_income_change": -0.0, "population": 109259.17191042229 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1159.0, + "average_household_income_change_upper": -850.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000418", "constituency_name": "Constituency 418", - "x": 14, - "y": 13, + "x": 17, + "y": 10, "average_household_income_change": -1004.6878933670484, "relative_household_income_change": -3.5e-9, "population": 91871.97834788615 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2231.0, + "average_household_income_change_upper": -1643.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000419", "constituency_name": "Constituency 419", - "x": 15, - "y": 13, + "x": 18, + "y": 10, "average_household_income_change": -1937.3634334742933, "relative_household_income_change": 0.0, "population": 115991.17237357103 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -105.0, + "average_household_income_change_upper": -72.0, + "relative_household_income_change_lower": 0.1651, + "relative_household_income_change_upper": 0.2244, "constituency_code": "E14000000420", "constituency_name": "Constituency 420", - "x": 16, - "y": 13, + "x": 19, + "y": 10, "average_household_income_change": -88.33450719112923, "relative_household_income_change": 0.19477066794069864, "population": 138651.37596406846 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2055.0, + "average_household_income_change_upper": -1513.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000421", "constituency_name": "Constituency 421", - "x": null, - "y": null, + "x": 20, + "y": 10, "average_household_income_change": -1783.8506563658334, "relative_household_income_change": -0.0, "population": 119996.31865550006 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -147.0, + "average_household_income_change_upper": -103.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000422", "constituency_name": "Constituency 422", - "x": 18, - "y": 13, + "x": 21, + "y": 10, "average_household_income_change": -124.97928653299084, "relative_household_income_change": -0.0, "population": 103284.53004234466 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 631.0, + "average_household_income_change_upper": 863.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000423", "constituency_name": "Constituency 423", - "x": 19, - "y": 13, + "x": 22, + "y": 10, "average_household_income_change": 746.9717492722236, "relative_household_income_change": 0.0, "population": 64314.765602602485 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -464.0, + "average_household_income_change_upper": -337.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000424", "constituency_name": "Constituency 424", - "x": 20, - "y": 13, + "x": 23, + "y": 10, "average_household_income_change": -400.21091127364275, "relative_household_income_change": -3.5e-9, "population": 126140.46522738156 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -13.0, + "average_household_income_change_upper": -4.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000425", "constituency_name": "Constituency 425", - "x": 21, - "y": 13, + "x": 24, + "y": 10, "average_household_income_change": -8.615886541627926, "relative_household_income_change": 1e-12, "population": 120500.20874521669 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1666.0, + "average_household_income_change_upper": -1225.0, + "relative_household_income_change_lower": 0.8489, + "relative_household_income_change_upper": 1.1496, "constituency_code": "E14000000426", "constituency_name": "Constituency 426", - "x": 22, - "y": 13, + "x": 25, + "y": 10, "average_household_income_change": -1445.5321322845048, "relative_household_income_change": 0.9992850483781154, "population": 78517.46219775046 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 217.0, + "average_household_income_change_upper": 302.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000427", "constituency_name": "Constituency 427", - "x": 23, - "y": 13, + "x": 26, + "y": 10, "average_household_income_change": 259.7025064134226, "relative_household_income_change": -3.5e-9, "population": 134534.28932349032 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1147.0, + "average_household_income_change_upper": 1560.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000428", "constituency_name": "Constituency 428", - "x": null, - "y": null, + "x": 27, + "y": 10, "average_household_income_change": 1353.384121501907, "relative_household_income_change": -3.5e-9, "population": 65122.60724567148 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1386.0, + "average_household_income_change_upper": -1018.0, + "relative_household_income_change_lower": 0.3715, + "relative_household_income_change_upper": 0.5036, "constituency_code": "E14000000429", "constituency_name": "Constituency 429", - "x": 25, - "y": 13, + "x": 28, + "y": 10, "average_household_income_change": -1202.1846279558727, "relative_household_income_change": 0.4375422625855504, "population": 106516.88841622724 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1114.0, + "average_household_income_change_upper": -818.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000430", "constituency_name": "Constituency 430", - "x": 26, - "y": 13, + "x": 29, + "y": 10, "average_household_income_change": -965.9386032117636, "relative_household_income_change": -3.5e-9, "population": 77348.31795576304 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -463.0, + "average_household_income_change_upper": -336.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000431", "constituency_name": "Constituency 431", - "x": 27, - "y": 13, + "x": 30, + "y": 10, "average_household_income_change": -399.31020340847834, "relative_household_income_change": -0.0, "population": 120119.21276118392 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1731.0, + "average_household_income_change_upper": -1273.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000432", "constituency_name": "Constituency 432", - "x": 28, - "y": 13, + "x": 31, + "y": 10, "average_household_income_change": -1501.7510086178102, "relative_household_income_change": -3.5e-9, "population": 123543.00333458264 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1587.0, + "average_household_income_change_upper": 2156.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000433", "constituency_name": "Constituency 433", - "x": 29, - "y": 13, + "x": 32, + "y": 10, "average_household_income_change": 1871.3633908875686, "relative_household_income_change": -0.0, "population": 114188.50689344354 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1014.0, + "average_household_income_change_upper": -744.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000434", "constituency_name": "Constituency 434", - "x": 30, - "y": 13, + "x": 33, + "y": 10, "average_household_income_change": -878.9359766848924, "relative_household_income_change": 0.0, "population": 137125.66547882586 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 15.0, + "average_household_income_change_upper": 29.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000435", "constituency_name": "Constituency 435", - "x": null, - "y": null, + "x": 34, + "y": 10, "average_household_income_change": 22.292631722566902, "relative_household_income_change": -3.5e-9, "population": 81603.38693974496 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -991.0, + "average_household_income_change_upper": -726.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000436", "constituency_name": "Constituency 436", - "x": 1, - "y": 14, + "x": 35, + "y": 10, "average_household_income_change": -858.5202969894166, "relative_household_income_change": -3.5e-9, "population": 134844.320946582 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 939.0, + "average_household_income_change_upper": 1279.0, + "relative_household_income_change_lower": 0.6774, + "relative_household_income_change_upper": 0.9175, "constituency_code": "E14000000437", "constituency_name": "Constituency 437", - "x": 2, - "y": 14, + "x": 36, + "y": 10, "average_household_income_change": 1108.7892653049796, "relative_household_income_change": 0.7974146129248088, "population": 109738.3225739174 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2232.0, + "average_household_income_change_upper": -1644.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000438", "constituency_name": "Constituency 438", - "x": 3, - "y": 14, + "x": 37, + "y": 10, "average_household_income_change": -1938.1221811323512, "relative_household_income_change": -3.5e-9, "population": 133395.10392182146 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 438.0, + "average_household_income_change_upper": 601.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000439", "constituency_name": "Constituency 439", - "x": 4, - "y": 14, + "x": 38, + "y": 10, "average_household_income_change": 519.5332360344125, "relative_household_income_change": -0.0, "population": 110056.89208575683 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1018.0, + "average_household_income_change_upper": 1385.0, + "relative_household_income_change_lower": 0.5377, + "relative_household_income_change_upper": 0.7285, "constituency_code": "E14000000440", "constituency_name": "Constituency 440", - "x": 5, - "y": 14, + "x": 39, + "y": 10, "average_household_income_change": 1201.6281562769175, "relative_household_income_change": 0.6330995821885036, "population": 139221.9302277582 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1196.0, + "average_household_income_change_upper": 1627.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000441", "constituency_name": "Constituency 441", - "x": 6, - "y": 14, + "x": 0, + "y": 11, "average_household_income_change": 1411.4779232097058, "relative_household_income_change": -0.0, "population": 98613.22805672791 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2130.0, + "average_household_income_change_upper": -1568.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000442", "constituency_name": "Constituency 442", - "x": null, - "y": null, + "x": 1, + "y": 11, "average_household_income_change": -1849.2825797469372, "relative_household_income_change": 1e-12, "population": 114277.94687681618 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1254.0, + "average_household_income_change_upper": 1705.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000443", "constituency_name": "Constituency 443", - "x": 8, - "y": 14, + "x": 2, + "y": 11, "average_household_income_change": 1479.1634504108054, "relative_household_income_change": -0.0, "population": 55027.6507208199 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -363.0, + "average_household_income_change_upper": -262.0, + "relative_household_income_change_lower": 0.5014, + "relative_household_income_change_upper": 0.6794, "constituency_code": "E14000000444", "constituency_name": "Constituency 444", - "x": 9, - "y": 14, + "x": 3, + "y": 11, "average_household_income_change": -312.3263436457391, "relative_household_income_change": 0.5903607041796785, "population": 134005.43950487245 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -275.0, + "average_household_income_change_upper": -197.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000445", "constituency_name": "Constituency 445", - "x": 10, - "y": 14, + "x": 4, + "y": 11, "average_household_income_change": -235.72010774130314, "relative_household_income_change": -0.0, "population": 107152.43583785828 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 232.0, + "average_household_income_change_upper": 322.0, + "relative_household_income_change_lower": 0.4361, + "relative_household_income_change_upper": 0.591, "constituency_code": "E14000000446", "constituency_name": "Constituency 446", - "x": 11, - "y": 14, + "x": 5, + "y": 11, "average_household_income_change": 277.1497954833628, "relative_household_income_change": 0.5135397749456319, "population": 70104.98439053618 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1551.0, + "average_household_income_change_upper": 2106.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000447", "constituency_name": "Constituency 447", - "x": 12, - "y": 14, + "x": 6, + "y": 11, "average_household_income_change": 1828.3291736397134, "relative_household_income_change": 0.0, "population": 105758.01403771316 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1818.0, + "average_household_income_change_upper": -1337.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000448", "constituency_name": "Constituency 448", - "x": 13, - "y": 14, + "x": 7, + "y": 11, "average_household_income_change": -1577.4235955150164, "relative_household_income_change": -0.0, "population": 84769.36787383328 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1586.0, + "average_household_income_change_upper": -1166.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000449", "constituency_name": "Constituency 449", - "x": null, - "y": null, + "x": 8, + "y": 11, "average_household_income_change": -1375.9862826114268, "relative_household_income_change": -3.5e-9, "population": 68155.11585838869 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1373.0, + "average_household_income_change_upper": 1866.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000450", "constituency_name": "Constituency 450", - "x": 15, - "y": 14, + "x": 9, + "y": 11, "average_household_income_change": 1619.7588245435081, "relative_household_income_change": 1e-12, "population": 108529.78607189572 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -154.0, + "average_household_income_change_upper": -107.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000451", "constituency_name": "Constituency 451", - "x": 16, - "y": 14, + "x": 10, + "y": 11, "average_household_income_change": -130.43885920074817, "relative_household_income_change": -3.5e-9, "population": 102164.24550005347 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1336.0, + "average_household_income_change_upper": 1816.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000452", "constituency_name": "Constituency 452", - "x": 17, - "y": 14, + "x": 11, + "y": 11, "average_household_income_change": 1575.9769085769442, "relative_household_income_change": -0.0, "population": 83074.55414942687 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1587.0, + "average_household_income_change_upper": -1167.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000453", "constituency_name": "Constituency 453", - "x": 18, - "y": 14, + "x": 12, + "y": 11, "average_household_income_change": -1376.5227309882753, "relative_household_income_change": 1e-12, "population": 76954.27379439081 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 20.0, + "average_household_income_change_upper": 36.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000454", "constituency_name": "Constituency 454", - "x": 19, - "y": 14, + "x": 13, + "y": 11, "average_household_income_change": 27.855930561574205, "relative_household_income_change": -3.5e-9, "population": 67970.8621895939 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 214.0, + "average_household_income_change_upper": 297.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000455", "constituency_name": "Constituency 455", - "x": 20, - "y": 14, + "x": 14, + "y": 11, "average_household_income_change": 255.4598736945679, "relative_household_income_change": -0.0, "population": 134897.99335933474 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1695.0, + "average_household_income_change_upper": -1247.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000456", "constituency_name": "Constituency 456", - "x": null, - "y": null, + "x": 15, + "y": 11, "average_household_income_change": -1470.9287179798966, "relative_household_income_change": -0.0, "population": 110214.6845204571 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1229.0, + "average_household_income_change_upper": -902.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000457", "constituency_name": "Constituency 457", - "x": 22, - "y": 14, + "x": 16, + "y": 11, "average_household_income_change": -1065.357672584253, "relative_household_income_change": 0.0, "population": 85225.7969482269 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1549.0, + "average_household_income_change_upper": 2104.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000458", "constituency_name": "Constituency 458", - "x": 23, - "y": 14, + "x": 17, + "y": 11, "average_household_income_change": 1826.5135187399758, "relative_household_income_change": 1e-12, "population": 98812.90540379459 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2025.0, + "average_household_income_change_upper": -1491.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000459", "constituency_name": "Constituency 459", - "x": 24, - "y": 14, + "x": 18, + "y": 11, "average_household_income_change": -1758.258007451244, "relative_household_income_change": 0.0, "population": 120834.59886771583 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1409.0, + "average_household_income_change_upper": 1914.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000460", "constituency_name": "Constituency 460", - "x": 25, - "y": 14, + "x": 19, + "y": 11, "average_household_income_change": 1661.4752599701808, "relative_household_income_change": -3.5e-9, "population": 140361.971015295 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 714.0, + "average_household_income_change_upper": 974.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000461", "constituency_name": "Constituency 461", - "x": 26, - "y": 14, + "x": 20, + "y": 11, "average_household_income_change": 843.8123826337538, "relative_household_income_change": -0.0, "population": 107298.60428240836 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -305.0, + "average_household_income_change_upper": -219.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000462", "constituency_name": "Constituency 462", - "x": 27, - "y": 14, + "x": 21, + "y": 11, "average_household_income_change": -261.89617854069394, "relative_household_income_change": -0.0, "population": 64880.04282406481 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -420.0, + "average_household_income_change_upper": -305.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000463", "constituency_name": "Constituency 463", - "x": null, - "y": null, + "x": 22, + "y": 11, "average_household_income_change": -362.59096189355546, "relative_household_income_change": 0.0, "population": 50746.49871824345 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1590.0, + "average_household_income_change_upper": -1169.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000464", "constituency_name": "Constituency 464", - "x": 29, - "y": 14, + "x": 23, + "y": 11, "average_household_income_change": -1379.3381831933407, "relative_household_income_change": -3.5e-9, "population": 81843.30796123961 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1034.0, + "average_household_income_change_upper": -758.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000465", "constituency_name": "Constituency 465", - "x": 30, - "y": 14, + "x": 24, + "y": 11, "average_household_income_change": -895.9319061295998, "relative_household_income_change": -0.0, "population": 86307.51948753887 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1206.0, + "average_household_income_change_upper": -885.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000466", "constituency_name": "Constituency 466", - "x": 0, - "y": 15, + "x": 25, + "y": 11, "average_household_income_change": -1045.5240756289488, "relative_household_income_change": 0.0, "population": 106630.97285104157 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -155.0, + "average_household_income_change_upper": -108.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000467", "constituency_name": "Constituency 467", - "x": 1, - "y": 15, + "x": 26, + "y": 11, "average_household_income_change": -131.40113141275344, "relative_household_income_change": 0.0, "population": 85625.67572050777 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -997.0, + "average_household_income_change_upper": -731.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000468", "constituency_name": "Constituency 468", - "x": 2, - "y": 15, + "x": 27, + "y": 11, "average_household_income_change": -864.2228005114356, "relative_household_income_change": 1e-12, "population": 110692.75990094521 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1404.0, + "average_household_income_change_upper": -1031.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000469", "constituency_name": "Constituency 469", - "x": 3, - "y": 15, + "x": 28, + "y": 11, "average_household_income_change": -1217.396077124258, "relative_household_income_change": 1e-12, "population": 95791.24250102474 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 693.0, + "average_household_income_change_upper": 946.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000470", "constituency_name": "Constituency 470", - "x": null, - "y": null, + "x": 29, + "y": 11, "average_household_income_change": 819.4095140623756, "relative_household_income_change": -3.5e-9, "population": 132591.75404400696 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -783.0, + "average_household_income_change_upper": -572.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000471", "constituency_name": "Constituency 471", - "x": 5, - "y": 15, + "x": 30, + "y": 11, "average_household_income_change": -677.4478721217139, "relative_household_income_change": -0.0, "population": 93372.07825937188 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 650.0, + "average_household_income_change_upper": 888.0, + "relative_household_income_change_lower": 0.2976, + "relative_household_income_change_upper": 0.4038, "constituency_code": "E14000000472", "constituency_name": "Constituency 472", - "x": 6, - "y": 15, + "x": 31, + "y": 11, "average_household_income_change": 769.2812919052758, "relative_household_income_change": 0.35070114616556214, "population": 82749.8384185931 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1549.0, + "average_household_income_change_upper": 2104.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000473", "constituency_name": "Constituency 473", - "x": 7, - "y": 15, + "x": 32, + "y": 11, "average_household_income_change": 1826.5823371023187, "relative_household_income_change": -0.0, "population": 94452.51692962078 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -986.0, + "average_household_income_change_upper": -723.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000474", "constituency_name": "Constituency 474", - "x": 8, - "y": 15, + "x": 33, + "y": 11, "average_household_income_change": -854.2150106850004, "relative_household_income_change": 0.0, "population": 73725.82272334029 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1548.0, + "average_household_income_change_upper": 2103.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000475", "constituency_name": "Constituency 475", - "x": 9, - "y": 15, + "x": 34, + "y": 11, "average_household_income_change": 1825.4016048874723, "relative_household_income_change": 0.0, "population": 77291.27368977931 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 181.0, + "average_household_income_change_upper": 253.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000476", "constituency_name": "Constituency 476", - "x": 10, - "y": 15, + "x": 35, + "y": 11, "average_household_income_change": 216.77639129025695, "relative_household_income_change": 0.0, "population": 77824.36965646 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1836.0, + "average_household_income_change_upper": -1351.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000477", "constituency_name": "Constituency 477", - "x": null, - "y": null, + "x": 36, + "y": 11, "average_household_income_change": -1593.3122775483603, "relative_household_income_change": -3.5e-9, "population": 111493.02905492586 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1455.0, + "average_household_income_change_upper": -1069.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000478", "constituency_name": "Constituency 478", - "x": 12, - "y": 15, + "x": 37, + "y": 11, "average_household_income_change": -1262.0158636617136, "relative_household_income_change": -0.0, "population": 57389.887600907605 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2001.0, + "average_household_income_change_upper": -1473.0, + "relative_household_income_change_lower": 0.4041, + "relative_household_income_change_upper": 0.5477, "constituency_code": "E14000000479", "constituency_name": "Constituency 479", - "x": 13, - "y": 15, + "x": 38, + "y": 11, "average_household_income_change": -1737.3547076698944, "relative_household_income_change": 0.4758874388681912, "population": 146184.4538265504 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1541.0, + "average_household_income_change_upper": -1133.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000480", "constituency_name": "Constituency 480", - "x": 14, - "y": 15, + "x": 39, + "y": 11, "average_household_income_change": -1337.146372116397, "relative_household_income_change": -0.0, "population": 99503.02345698366 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -513.0, + "average_household_income_change_upper": -373.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000481", "constituency_name": "Constituency 481", - "x": 15, - "y": 15, + "x": 0, + "y": 12, "average_household_income_change": -443.31641902452026, "relative_household_income_change": 1e-12, "population": 95722.83718255228 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 422.0, + "average_household_income_change_upper": 579.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000482", "constituency_name": "Constituency 482", - "x": 16, - "y": 15, + "x": 1, + "y": 12, "average_household_income_change": 500.8423169444973, "relative_household_income_change": 1e-12, "population": 93764.74125416626 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -416.0, + "average_household_income_change_upper": -302.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000483", "constituency_name": "Constituency 483", - "x": 17, - "y": 15, + "x": 2, + "y": 12, "average_household_income_change": -359.07471826969186, "relative_household_income_change": 0.0, "population": 138311.96152862016 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1183.0, + "average_household_income_change_upper": 1609.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000484", "constituency_name": "Constituency 484", - "x": null, - "y": null, + "x": 3, + "y": 12, "average_household_income_change": 1395.8256437349855, "relative_household_income_change": -0.0, "population": 86636.26588590886 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 810.0, + "average_household_income_change_upper": 1105.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000485", "constituency_name": "Constituency 485", - "x": 19, - "y": 15, + "x": 4, + "y": 12, "average_household_income_change": 957.414181435833, "relative_household_income_change": -3.5e-9, "population": 68778.62572790433 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 41.0, + "average_household_income_change_upper": 63.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000486", "constituency_name": "Constituency 486", - "x": 20, - "y": 15, + "x": 5, + "y": 12, "average_household_income_change": 52.068594084960566, "relative_household_income_change": -0.0, "population": 149629.3667680624 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -538.0, + "average_household_income_change_upper": -392.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000487", "constituency_name": "Constituency 487", - "x": 21, - "y": 15, + "x": 6, + "y": 12, "average_household_income_change": -464.85862856143, "relative_household_income_change": -0.0, "population": 110352.0278947326 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1496.0, + "average_household_income_change_upper": 2032.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000488", "constituency_name": "Constituency 488", - "x": 22, - "y": 15, + "x": 7, + "y": 12, "average_household_income_change": 1764.0835881287157, "relative_household_income_change": 1e-12, "population": 62384.47166940049 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 585.0, + "average_household_income_change_upper": 800.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000489", "constituency_name": "Constituency 489", - "x": 23, - "y": 15, + "x": 8, + "y": 12, "average_household_income_change": 692.3998314092001, "relative_household_income_change": 0.0, "population": 85408.80666901525 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1357.0, + "average_household_income_change_upper": 1845.0, + "relative_household_income_change_lower": 0.2763, + "relative_household_income_change_upper": 0.3749, "constituency_code": "E14000000490", "constituency_name": "Constituency 490", - "x": 24, - "y": 15, + "x": 9, + "y": 12, "average_household_income_change": 1601.0227417620797, "relative_household_income_change": 0.3256023747916895, "population": 127392.7504156333 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1067.0, + "average_household_income_change_upper": -782.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000491", "constituency_name": "Constituency 491", - "x": null, - "y": null, + "x": 10, + "y": 12, "average_household_income_change": -924.525713053443, "relative_household_income_change": 1e-12, "population": 115834.91080642746 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1363.0, + "average_household_income_change_upper": 1852.0, + "relative_household_income_change_lower": 0.0729, + "relative_household_income_change_upper": 0.0996, "constituency_code": "E14000000492", "constituency_name": "Constituency 492", - "x": 26, - "y": 15, + "x": 11, + "y": 12, "average_household_income_change": 1607.7828851379104, "relative_household_income_change": 0.08626010051598998, "population": 116576.36483302494 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 330.0, + "average_household_income_change_upper": 455.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000493", "constituency_name": "Constituency 493", - "x": 27, - "y": 15, + "x": 12, + "y": 12, "average_household_income_change": 392.5900111746632, "relative_household_income_change": -0.0, "population": 132360.86716965502 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 187.0, + "average_household_income_change_upper": 261.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000494", "constituency_name": "Constituency 494", - "x": 28, - "y": 15, + "x": 13, + "y": 12, "average_household_income_change": 223.9340392642153, "relative_household_income_change": -3.5e-9, "population": 134846.52691992815 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1222.0, + "average_household_income_change_upper": 1661.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000495", "constituency_name": "Constituency 495", - "x": 29, - "y": 15, + "x": 14, + "y": 12, "average_household_income_change": 1441.2988042773184, "relative_household_income_change": 0.0, "population": 137986.4965672183 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -577.0, + "average_household_income_change_upper": -420.0, + "relative_household_income_change_lower": 0.2761, + "relative_household_income_change_upper": 0.3745, "constituency_code": "E14000000496", "constituency_name": "Constituency 496", - "x": 30, - "y": 15, + "x": 15, + "y": 12, "average_household_income_change": -498.7039191058127, "relative_household_income_change": 0.3252945301780501, "population": 143405.9946706618 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 960.0, + "average_household_income_change_upper": 1307.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000497", "constituency_name": "Constituency 497", - "x": 0, - "y": 16, + "x": 16, + "y": 12, "average_household_income_change": 1133.1963374325728, "relative_household_income_change": 1e-12, "population": 68426.46908976509 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1316.0, + "average_household_income_change_upper": 1789.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000498", "constituency_name": "Constituency 498", - "x": null, - "y": null, + "x": 17, + "y": 12, "average_household_income_change": 1552.2279517645652, "relative_household_income_change": 1e-12, "population": 69801.21947574713 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2226.0, + "average_household_income_change_upper": -1640.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000499", "constituency_name": "Constituency 499", - "x": 2, - "y": 16, + "x": 18, + "y": 12, "average_household_income_change": -1933.0121573332403, "relative_household_income_change": -0.0, "population": 71810.7127554828 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1574.0, + "average_household_income_change_upper": 2138.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000500", "constituency_name": "Constituency 500", - "x": 3, - "y": 16, + "x": 19, + "y": 12, "average_household_income_change": 1856.020820102462, "relative_household_income_change": -0.0, "population": 115990.60962848747 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1645.0, + "average_household_income_change_upper": 2234.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000501", "constituency_name": "Constituency 501", - "x": 4, - "y": 16, + "x": 20, + "y": 12, "average_household_income_change": 1939.131245149897, "relative_household_income_change": 0.0, "population": 145774.10814719292 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1395.0, + "average_household_income_change_upper": 1895.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000502", "constituency_name": "Constituency 502", - "x": 5, - "y": 16, + "x": 21, + "y": 12, "average_household_income_change": 1644.716537294447, "relative_household_income_change": -0.0, "population": 115658.11807754858 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1886.0, + "average_household_income_change_upper": -1388.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000503", "constituency_name": "Constituency 503", - "x": 6, - "y": 16, + "x": 22, + "y": 12, "average_household_income_change": -1636.9003200301875, "relative_household_income_change": 0.0, "population": 89531.70714848021 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 72.0, + "average_household_income_change_upper": 106.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000504", "constituency_name": "Constituency 504", - "x": 7, - "y": 16, + "x": 23, + "y": 12, "average_household_income_change": 88.92124000860102, "relative_household_income_change": 1e-12, "population": 125688.2659888468 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1520.0, + "average_household_income_change_upper": -1117.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000505", "constituency_name": "Constituency 505", - "x": null, - "y": null, + "x": 24, + "y": 12, "average_household_income_change": -1318.7744512100821, "relative_household_income_change": 0.0, "population": 91088.20168102277 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1356.0, + "average_household_income_change_upper": -996.0, + "relative_household_income_change_lower": 0.2598, + "relative_household_income_change_upper": 0.3525, "constituency_code": "E14000000506", "constituency_name": "Constituency 506", - "x": 9, - "y": 16, + "x": 25, + "y": 12, "average_household_income_change": -1175.9495393146576, "relative_household_income_change": 0.30616875596009807, "population": 131214.95491923267 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1659.0, + "average_household_income_change_upper": -1220.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000507", "constituency_name": "Constituency 507", - "x": 10, - "y": 16, + "x": 26, + "y": 12, "average_household_income_change": -1439.7064451060162, "relative_household_income_change": -3.5e-9, "population": 119035.7114330096 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 444.0, + "average_household_income_change_upper": 608.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000508", "constituency_name": "Constituency 508", - "x": 11, - "y": 16, + "x": 27, + "y": 12, "average_household_income_change": 525.9315378974767, "relative_household_income_change": 0.0, "population": 149780.60570252594 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1576.0, + "average_household_income_change_upper": -1159.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000509", "constituency_name": "Constituency 509", - "x": 12, - "y": 16, + "x": 28, + "y": 12, "average_household_income_change": -1367.1976497187427, "relative_household_income_change": 0.0, "population": 99775.86963739073 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 575.0, + "average_household_income_change_upper": 787.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000510", "constituency_name": "Constituency 510", - "x": 13, - "y": 16, + "x": 29, + "y": 12, "average_household_income_change": 681.0185339591745, "relative_household_income_change": 0.0, "population": 52250.53683271075 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 601.0, + "average_household_income_change_upper": 821.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000511", "constituency_name": "Constituency 511", - "x": 14, - "y": 16, + "x": 30, + "y": 12, "average_household_income_change": 711.0597691914737, "relative_household_income_change": -0.0, "population": 73213.28057757275 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 412.0, + "average_household_income_change_upper": 566.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000512", "constituency_name": "Constituency 512", - "x": null, - "y": null, + "x": 31, + "y": 12, "average_household_income_change": 488.84935913679016, "relative_household_income_change": 0.0, "population": 125981.89147263978 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1380.0, + "average_household_income_change_upper": -1014.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000513", "constituency_name": "Constituency 513", - "x": 16, - "y": 16, + "x": 32, + "y": 12, "average_household_income_change": -1196.9661233102288, "relative_household_income_change": -3.5e-9, "population": 128616.27263812006 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1305.0, + "average_household_income_change_upper": 1774.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000514", "constituency_name": "Constituency 514", - "x": 17, - "y": 16, + "x": 33, + "y": 12, "average_household_income_change": 1539.7654878967505, "relative_household_income_change": 1e-12, "population": 104552.41760001461 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 561.0, + "average_household_income_change_upper": 768.0, + "relative_household_income_change_lower": 0.778, + "relative_household_income_change_upper": 1.0536, "constituency_code": "E14000000515", "constituency_name": "Constituency 515", - "x": 18, - "y": 16, + "x": 34, + "y": 12, "average_household_income_change": 664.5980280202077, "relative_household_income_change": 0.9157729795254148, "population": 132900.03832707356 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 740.0, + "average_household_income_change_upper": 1010.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000516", "constituency_name": "Constituency 516", - "x": 19, - "y": 16, + "x": 35, + "y": 12, "average_household_income_change": 874.7981354443475, "relative_household_income_change": -0.0, "population": 139599.38195812594 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -110.0, + "average_household_income_change_upper": -75.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000517", "constituency_name": "Constituency 517", - "x": 20, - "y": 16, + "x": 36, + "y": 12, "average_household_income_change": -92.51945756994174, "relative_household_income_change": -0.0, "population": 127203.73196954656 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1188.0, + "average_household_income_change_upper": 1616.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000518", "constituency_name": "Constituency 518", - "x": 21, - "y": 16, + "x": 37, + "y": 12, "average_household_income_change": 1401.7863678742444, "relative_household_income_change": 0.0, "population": 101400.21509992852 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 805.0, + "average_household_income_change_upper": 1097.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000519", "constituency_name": "Constituency 519", - "x": null, - "y": null, + "x": 38, + "y": 12, "average_household_income_change": 950.872054844548, "relative_household_income_change": -3.5e-9, "population": 102808.51547701252 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 224.0, + "average_household_income_change_upper": 311.0, + "relative_household_income_change_lower": 0.7477, + "relative_household_income_change_upper": 1.0126, "constituency_code": "E14000000520", "constituency_name": "Constituency 520", - "x": 23, - "y": 16, + "x": 39, + "y": 12, "average_household_income_change": 267.1213839802129, "relative_household_income_change": 0.8801315917911949, "population": 113871.08181816454 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1343.0, + "average_household_income_change_upper": 1825.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000521", "constituency_name": "Constituency 521", - "x": 24, - "y": 16, + "x": 0, + "y": 13, "average_household_income_change": 1583.6668839155411, "relative_household_income_change": -0.0, "population": 76415.2914843896 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 399.0, + "average_household_income_change_upper": 548.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000522", "constituency_name": "Constituency 522", - "x": 25, - "y": 16, + "x": 1, + "y": 13, "average_household_income_change": 473.62702776804167, "relative_household_income_change": -0.0, "population": 110938.16395304399 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1409.0, + "average_household_income_change_upper": 1914.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000523", "constituency_name": "Constituency 523", - "x": 26, - "y": 16, + "x": 2, + "y": 13, "average_household_income_change": 1661.2704907714462, "relative_household_income_change": 1e-12, "population": 86960.28366122275 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1423.0, + "average_household_income_change_upper": 1933.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000524", "constituency_name": "Constituency 524", - "x": 27, - "y": 16, + "x": 3, + "y": 13, "average_household_income_change": 1677.6832707399083, "relative_household_income_change": -0.0, "population": 62627.66271913964 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 24.0, + "average_household_income_change_upper": 41.0, + "relative_household_income_change_lower": 0.6845, + "relative_household_income_change_upper": 0.9271, "constituency_code": "E14000000525", "constituency_name": "Constituency 525", - "x": 28, - "y": 16, + "x": 4, + "y": 13, "average_household_income_change": 32.65960657239475, "relative_household_income_change": 0.8058113644644113, "population": 88512.16871276736 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 810.0, + "average_household_income_change_upper": 1104.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000526", "constituency_name": "Constituency 526", - "x": null, - "y": null, + "x": 5, + "y": 13, "average_household_income_change": 956.6026427007084, "relative_household_income_change": 1e-12, "population": 67177.90548895177 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -95.0, + "average_household_income_change_upper": -64.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000527", "constituency_name": "Constituency 527", - "x": 30, - "y": 16, + "x": 6, + "y": 13, "average_household_income_change": -79.65777433093812, "relative_household_income_change": -0.0, "population": 129359.15970497992 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1763.0, + "average_household_income_change_upper": -1297.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000528", "constituency_name": "Constituency 528", - "x": 0, - "y": 17, + "x": 7, + "y": 13, "average_household_income_change": -1530.3875617109775, "relative_household_income_change": -3.5e-9, "population": 122544.81803253728 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -797.0, + "average_household_income_change_upper": -583.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000529", "constituency_name": "Constituency 529", - "x": 1, - "y": 17, + "x": 8, + "y": 13, "average_household_income_change": -690.1267696520264, "relative_household_income_change": -0.0, "population": 74964.53753227353 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1544.0, + "average_household_income_change_upper": 2098.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000530", "constituency_name": "Constituency 530", - "x": 2, - "y": 17, + "x": 9, + "y": 13, "average_household_income_change": 1821.0119974157406, "relative_household_income_change": -3.5e-9, "population": 117617.45171258436 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 173.0, + "average_household_income_change_upper": 243.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000531", "constituency_name": "Constituency 531", - "x": 3, - "y": 17, + "x": 10, + "y": 13, "average_household_income_change": 208.0498046541739, "relative_household_income_change": -3.5e-9, "population": 143004.6731627013 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 17.0, + "average_household_income_change_upper": 32.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000532", "constituency_name": "Constituency 532", - "x": 4, - "y": 17, + "x": 11, + "y": 13, "average_household_income_change": 24.455900708818756, "relative_household_income_change": 0.0, "population": 74337.36080179275 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1682.0, + "average_household_income_change_upper": 2284.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000533", "constituency_name": "Constituency 533", - "x": null, - "y": null, + "x": 12, + "y": 13, "average_household_income_change": 1983.1116832288517, "relative_household_income_change": -3.5e-9, "population": 117952.23903240418 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 870.0, + "average_household_income_change_upper": 1185.0, + "relative_household_income_change_lower": 0.5132, + "relative_household_income_change_upper": 0.6954, "constituency_code": "E14000000534", "constituency_name": "Constituency 534", - "x": 6, - "y": 17, + "x": 13, + "y": 13, "average_household_income_change": 1027.6081219737403, "relative_household_income_change": 0.6042977436890922, "population": 122635.04116619783 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 747.0, + "average_household_income_change_upper": 1019.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000535", "constituency_name": "Constituency 535", - "x": 7, - "y": 17, + "x": 14, + "y": 13, "average_household_income_change": 883.3729304827402, "relative_household_income_change": -0.0, "population": 84563.45632753313 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -481.0, + "average_household_income_change_upper": -349.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000536", "constituency_name": "Constituency 536", - "x": 8, - "y": 17, + "x": 15, + "y": 13, "average_household_income_change": -415.0102749434339, "relative_household_income_change": -3.5e-9, "population": 139019.39363636472 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1073.0, + "average_household_income_change_upper": -787.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000537", "constituency_name": "Constituency 537", - "x": 9, - "y": 17, + "x": 16, + "y": 13, "average_household_income_change": -929.7356907140188, "relative_household_income_change": -3.5e-9, "population": 62512.41742705785 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2300.0, + "average_household_income_change_upper": -1694.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000538", "constituency_name": "Constituency 538", - "x": 10, - "y": 17, + "x": 17, + "y": 13, "average_household_income_change": -1996.7697301134376, "relative_household_income_change": 0.0, "population": 90369.34146142566 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1528.0, + "average_household_income_change_upper": 2075.0, + "relative_household_income_change_lower": 0.669, + "relative_household_income_change_upper": 0.9062, "constituency_code": "E14000000539", "constituency_name": "Constituency 539", - "x": 11, - "y": 17, + "x": 18, + "y": 13, "average_household_income_change": 1801.4507622204592, "relative_household_income_change": 0.7875965623100697, "population": 118552.27021021653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -657.0, + "average_household_income_change_upper": -480.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000540", "constituency_name": "Constituency 540", - "x": null, - "y": null, + "x": 19, + "y": 13, "average_household_income_change": -568.5524660509858, "relative_household_income_change": -3.5e-9, "population": 129523.3537724613 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1078.0, + "average_household_income_change_upper": 1467.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000541", "constituency_name": "Constituency 541", - "x": 13, - "y": 17, + "x": 20, + "y": 13, "average_household_income_change": 1272.6941414223477, "relative_household_income_change": -0.0, "population": 57640.64659864336 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -886.0, + "average_household_income_change_upper": -649.0, + "relative_household_income_change_lower": 0.2849, + "relative_household_income_change_upper": 0.3865, "constituency_code": "E14000000542", "constituency_name": "Constituency 542", - "x": 14, - "y": 17, + "x": 21, + "y": 13, "average_household_income_change": -767.5057915657742, "relative_household_income_change": 0.3357304975304993, "population": 115692.15832822133 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 56.0, + "average_household_income_change_upper": 84.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000543", "constituency_name": "Constituency 543", - "x": 15, - "y": 17, + "x": 22, + "y": 13, "average_household_income_change": 70.06268109612046, "relative_household_income_change": 0.0, "population": 87007.97793879958 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1668.0, + "average_household_income_change_upper": 2265.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000544", "constituency_name": "Constituency 544", - "x": 16, - "y": 17, + "x": 23, + "y": 13, "average_household_income_change": 1966.6092641458092, "relative_household_income_change": 0.0, "population": 114343.38680480534 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -407.0, + "average_household_income_change_upper": -295.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000545", "constituency_name": "Constituency 545", - "x": 17, - "y": 17, + "x": 24, + "y": 13, "average_household_income_change": -350.98957610023353, "relative_household_income_change": 1e-12, "population": 121673.71687105054 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 239.0, + "average_household_income_change_upper": 331.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000546", "constituency_name": "Constituency 546", - "x": 18, - "y": 17, + "x": 25, + "y": 13, "average_household_income_change": 284.78850562685795, "relative_household_income_change": -3.5e-9, "population": 90107.91467113272 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 456.0, + "average_household_income_change_upper": 625.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000547", "constituency_name": "Constituency 547", - "x": null, - "y": null, + "x": 26, + "y": 13, "average_household_income_change": 540.7926305781476, "relative_household_income_change": -0.0, "population": 77857.17106791587 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1333.0, + "average_household_income_change_upper": 1812.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000548", "constituency_name": "Constituency 548", - "x": 20, - "y": 17, + "x": 27, + "y": 13, "average_household_income_change": 1572.495720645288, "relative_household_income_change": 1e-12, "population": 63776.77103353226 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 665.0, + "average_household_income_change_upper": 908.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000549", "constituency_name": "Constituency 549", - "x": 21, - "y": 17, + "x": 28, + "y": 13, "average_household_income_change": 786.1815131891445, "relative_household_income_change": 0.0, "population": 72317.31785925994 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 422.0, + "average_household_income_change_upper": 580.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000550", "constituency_name": "Constituency 550", - "x": 22, - "y": 17, + "x": 29, + "y": 13, "average_household_income_change": 501.0373161137469, "relative_household_income_change": -3.5e-9, "population": 51968.97767258395 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1007.0, + "average_household_income_change_upper": -738.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000551", "constituency_name": "Constituency 551", - "x": 23, - "y": 17, + "x": 30, + "y": 13, "average_household_income_change": -872.3149541947821, "relative_household_income_change": 0.0, "population": 103860.54079116536 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1993.0, + "average_household_income_change_upper": -1467.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000552", "constituency_name": "Constituency 552", - "x": 24, - "y": 17, + "x": 31, + "y": 13, "average_household_income_change": -1729.9601043319988, "relative_household_income_change": 0.0, "population": 144540.067456093 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1361.0, + "average_household_income_change_upper": -1000.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000553", "constituency_name": "Constituency 553", - "x": 25, - "y": 17, + "x": 32, + "y": 13, "average_household_income_change": -1180.1590196030402, "relative_household_income_change": 1e-12, "population": 66543.61008387458 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 46.0, + "average_household_income_change_upper": 71.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000554", "constituency_name": "Constituency 554", - "x": null, - "y": null, + "x": 33, + "y": 13, "average_household_income_change": 58.6742524695163, "relative_household_income_change": 1e-12, "population": 141632.26250944004 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1589.0, + "average_household_income_change_upper": -1169.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000555", "constituency_name": "Constituency 555", - "x": 27, - "y": 17, + "x": 34, + "y": 13, "average_household_income_change": -1379.002800676671, "relative_household_income_change": 1e-12, "population": 65107.374171665964 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1769.0, + "average_household_income_change_upper": -1301.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000556", "constituency_name": "Constituency 556", - "x": 28, - "y": 17, + "x": 35, + "y": 13, "average_household_income_change": -1534.8529282878776, "relative_household_income_change": -3.5e-9, "population": 98585.83289728437 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1012.0, + "average_household_income_change_upper": 1378.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000557", "constituency_name": "Constituency 557", - "x": 29, - "y": 17, + "x": 36, + "y": 13, "average_household_income_change": 1195.252276686837, "relative_household_income_change": 0.0, "population": 121134.06333908037 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -679.0, + "average_household_income_change_upper": -496.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000558", "constituency_name": "Constituency 558", - "x": 30, - "y": 17, + "x": 37, + "y": 13, "average_household_income_change": -587.1983257619266, "relative_household_income_change": 1e-12, "population": 56035.946122824156 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1533.0, + "average_household_income_change_upper": -1127.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000559", "constituency_name": "Constituency 559", - "x": 0, - "y": 18, + "x": 38, + "y": 13, "average_household_income_change": -1329.7265899480517, "relative_household_income_change": 0.0, "population": 75557.55189169839 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1447.0, + "average_household_income_change_upper": -1064.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000560", "constituency_name": "Constituency 560", - "x": 1, - "y": 18, + "x": 39, + "y": 13, "average_household_income_change": -1255.3949599405319, "relative_household_income_change": 1e-12, "population": 94964.38366509751 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1571.0, + "average_household_income_change_upper": 2134.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000561", "constituency_name": "Constituency 561", - "x": null, - "y": null, + "x": 0, + "y": 14, "average_household_income_change": 1852.312025967675, "relative_household_income_change": 0.0, "population": 124320.79269576802 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1031.0, + "average_household_income_change_upper": 1403.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000562", "constituency_name": "Constituency 562", - "x": 3, - "y": 18, + "x": 1, + "y": 14, "average_household_income_change": 1216.5399142250092, "relative_household_income_change": -3.5e-9, "population": 72214.34664091107 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 792.0, + "average_household_income_change_upper": 1080.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000563", "constituency_name": "Constituency 563", - "x": 4, - "y": 18, + "x": 2, + "y": 14, "average_household_income_change": 936.0199128578968, "relative_household_income_change": -0.0, "population": 52121.195242426424 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 388.0, + "average_household_income_change_upper": 533.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000564", "constituency_name": "Constituency 564", - "x": 5, - "y": 18, + "x": 3, + "y": 14, "average_household_income_change": 460.4087216784598, "relative_household_income_change": 0.0, "population": 132579.04007382633 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1416.0, + "average_household_income_change_upper": -1040.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000565", "constituency_name": "Constituency 565", - "x": 6, - "y": 18, + "x": 4, + "y": 14, "average_household_income_change": -1228.017075495524, "relative_household_income_change": 1e-12, "population": 127756.68581745084 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 837.0, + "average_household_income_change_upper": 1141.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000566", "constituency_name": "Constituency 566", - "x": 7, - "y": 18, + "x": 5, + "y": 14, "average_household_income_change": 989.0043124308359, "relative_household_income_change": 1e-12, "population": 96691.3995080644 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 535.0, + "average_household_income_change_upper": 732.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000567", "constituency_name": "Constituency 567", - "x": 8, - "y": 18, + "x": 6, + "y": 14, "average_household_income_change": 633.2752797320067, "relative_household_income_change": -3.5e-9, "population": 105847.73106323073 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1304.0, + "average_household_income_change_upper": 1772.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000568", "constituency_name": "Constituency 568", - "x": null, - "y": null, + "x": 7, + "y": 14, "average_household_income_change": 1537.6835760239028, "relative_household_income_change": -0.0, "population": 59574.52355411442 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 477.0, + "average_household_income_change_upper": 653.0, + "relative_household_income_change_lower": 0.3686, + "relative_household_income_change_upper": 0.4998, "constituency_code": "E14000000569", "constituency_name": "Constituency 569", - "x": 10, - "y": 18, + "x": 8, + "y": 14, "average_household_income_change": 564.9755937017145, "relative_household_income_change": 0.4342081333144031, "population": 122732.6148148665 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1612.0, + "average_household_income_change_upper": 2190.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000570", "constituency_name": "Constituency 570", - "x": 11, - "y": 18, + "x": 9, + "y": 14, "average_household_income_change": 1901.1059243448528, "relative_household_income_change": -3.5e-9, "population": 86291.08878565823 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1397.0, + "average_household_income_change_upper": 1898.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000571", "constituency_name": "Constituency 571", - "x": 12, - "y": 18, + "x": 10, + "y": 14, "average_household_income_change": 1647.0976068910384, "relative_household_income_change": 0.0, "population": 83118.92406031303 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 777.0, + "average_household_income_change_upper": 1059.0, + "relative_household_income_change_lower": 0.6066, + "relative_household_income_change_upper": 0.8217, "constituency_code": "E14000000572", "constituency_name": "Constituency 572", - "x": 13, - "y": 18, + "x": 11, + "y": 14, "average_household_income_change": 917.6942423765499, "relative_household_income_change": 0.7141296082160444, "population": 83980.99861545017 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -918.0, + "average_household_income_change_upper": -672.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000573", "constituency_name": "Constituency 573", - "x": 14, - "y": 18, + "x": 12, + "y": 14, "average_household_income_change": -794.8516659664629, "relative_household_income_change": -0.0, "population": 116611.44894635049 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 659.0, + "average_household_income_change_upper": 900.0, + "relative_household_income_change_lower": 0.813, + "relative_household_income_change_upper": 1.101, "constituency_code": "E14000000574", "constituency_name": "Constituency 574", - "x": 15, - "y": 18, + "x": 13, + "y": 14, "average_household_income_change": 779.5916049469769, "relative_household_income_change": 0.9569943470482749, "population": 56838.49468248409 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1401.0, + "average_household_income_change_upper": 1904.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000575", "constituency_name": "Constituency 575", - "x": null, - "y": null, + "x": 14, + "y": 14, "average_household_income_change": 1652.578569337204, "relative_household_income_change": -0.0, "population": 104940.63353614656 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 258.0, + "average_household_income_change_upper": 357.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000576", "constituency_name": "Constituency 576", - "x": 17, - "y": 18, + "x": 15, + "y": 14, "average_household_income_change": 307.4118121606677, "relative_household_income_change": 1e-12, "population": 126993.11969852142 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 627.0, + "average_household_income_change_upper": 856.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000577", "constituency_name": "Constituency 577", - "x": 18, - "y": 18, + "x": 16, + "y": 14, "average_household_income_change": 741.5070307134765, "relative_household_income_change": 1e-12, "population": 119936.8884747448 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -340.0, + "average_household_income_change_upper": -246.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000578", "constituency_name": "Constituency 578", - "x": 19, - "y": 18, + "x": 17, + "y": 14, "average_household_income_change": -293.01348432114384, "relative_household_income_change": -0.0, "population": 139054.8255490753 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 959.0, + "average_household_income_change_upper": 1305.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000579", "constituency_name": "Constituency 579", - "x": 20, - "y": 18, + "x": 18, + "y": 14, "average_household_income_change": 1131.8770630588108, "relative_household_income_change": 0.0, "population": 72826.75061051587 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -729.0, + "average_household_income_change_upper": -533.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000580", "constituency_name": "Constituency 580", - "x": 21, - "y": 18, + "x": 19, + "y": 14, "average_household_income_change": -630.9564099040315, "relative_household_income_change": -3.5e-9, "population": 56888.615982387026 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1318.0, + "average_household_income_change_upper": 1791.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000581", "constituency_name": "Constituency 581", - "x": 22, - "y": 18, + "x": 20, + "y": 14, "average_household_income_change": 1554.3278310363116, "relative_household_income_change": 0.0, "population": 145841.03237646338 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 813.0, + "average_household_income_change_upper": 1108.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000582", "constituency_name": "Constituency 582", - "x": null, - "y": null, + "x": 21, + "y": 14, "average_household_income_change": 960.0847099843812, "relative_household_income_change": -0.0, "population": 137570.1463791927 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 696.0, + "average_household_income_change_upper": 950.0, + "relative_household_income_change_lower": 0.7327, + "relative_household_income_change_upper": 0.9924, "constituency_code": "E14000000583", "constituency_name": "Constituency 583", - "x": 24, - "y": 18, + "x": 22, + "y": 14, "average_household_income_change": 823.4577882062744, "relative_household_income_change": 0.8625367572015296, "population": 130636.04667489542 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -84.0, + "average_household_income_change_upper": -56.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000584", "constituency_name": "Constituency 584", - "x": 25, - "y": 18, + "x": 23, + "y": 14, "average_household_income_change": -70.09732998103146, "relative_household_income_change": 0.0, "population": 70802.83526568713 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1350.0, + "average_household_income_change_upper": 1835.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000585", "constituency_name": "Constituency 585", - "x": 26, - "y": 18, + "x": 24, + "y": 14, "average_household_income_change": 1592.447556354229, "relative_household_income_change": 1e-12, "population": 60038.29592953278 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1324.0, + "average_household_income_change_upper": 1799.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000586", "constituency_name": "Constituency 586", - "x": 27, - "y": 18, + "x": 25, + "y": 14, "average_household_income_change": 1561.290651214074, "relative_household_income_change": 0.0, "population": 123015.32138698698 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 75.0, + "average_household_income_change_upper": 110.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000587", "constituency_name": "Constituency 587", - "x": 28, - "y": 18, + "x": 26, + "y": 14, "average_household_income_change": 92.60827995758403, "relative_household_income_change": -3.5e-9, "population": 74372.69820872009 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1999.0, + "average_household_income_change_upper": -1471.0, + "relative_household_income_change_lower": 0.735, + "relative_household_income_change_upper": 0.9955, "constituency_code": "E14000000588", "constituency_name": "Constituency 588", - "x": 29, - "y": 18, + "x": 27, + "y": 14, "average_household_income_change": -1735.1883462164376, "relative_household_income_change": 0.8652316608891197, "population": 107990.85608582196 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -845.0, + "average_household_income_change_upper": -619.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000589", "constituency_name": "Constituency 589", - "x": null, - "y": null, + "x": 28, + "y": 14, "average_household_income_change": -731.794768168398, "relative_household_income_change": -0.0, "population": 133607.86126488214 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 952.0, + "average_household_income_change_upper": 1296.0, + "relative_household_income_change_lower": 0.8439, + "relative_household_income_change_upper": 1.1428, "constituency_code": "E14000000590", "constituency_name": "Constituency 590", - "x": 0, - "y": 19, + "x": 29, + "y": 14, "average_household_income_change": 1124.1223787780555, "relative_household_income_change": 0.9933078591651776, "population": 55151.75031185687 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -929.0, + "average_household_income_change_upper": -681.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000591", "constituency_name": "Constituency 591", - "x": 1, - "y": 19, + "x": 30, + "y": 14, "average_household_income_change": -804.9034884896406, "relative_household_income_change": 0.0, "population": 132303.99221559628 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -309.0, + "average_household_income_change_upper": -222.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000592", "constituency_name": "Constituency 592", - "x": 2, - "y": 19, + "x": 31, + "y": 14, "average_household_income_change": -265.5169125780508, "relative_household_income_change": -3.5e-9, "population": 53366.22724187432 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2259.0, + "average_household_income_change_upper": -1664.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000593", "constituency_name": "Constituency 593", - "x": 3, - "y": 19, + "x": 32, + "y": 14, "average_household_income_change": -1961.2404528225684, "relative_household_income_change": 1e-12, "population": 97782.92636483927 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 403.0, + "average_household_income_change_upper": 553.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000594", "constituency_name": "Constituency 594", - "x": 4, - "y": 19, + "x": 33, + "y": 14, "average_household_income_change": 477.65139708514994, "relative_household_income_change": 1e-12, "population": 84078.00961891914 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -632.0, + "average_household_income_change_upper": -461.0, + "relative_household_income_change_lower": 0.2873, + "relative_household_income_change_upper": 0.3898, "constituency_code": "E14000000595", "constituency_name": "Constituency 595", - "x": 5, - "y": 19, + "x": 34, + "y": 14, "average_household_income_change": -546.6949474395025, "relative_household_income_change": 0.3385683294268179, "population": 76236.94173217926 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1401.0, + "average_household_income_change_upper": 1904.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000596", "constituency_name": "Constituency 596", - "x": null, - "y": null, + "x": 35, + "y": 14, "average_household_income_change": 1652.2575803141276, "relative_household_income_change": -0.0, "population": 89450.32745730813 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2046.0, + "average_household_income_change_upper": -1506.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000597", "constituency_name": "Constituency 597", - "x": 7, - "y": 19, + "x": 36, + "y": 14, "average_household_income_change": -1775.7536371573299, "relative_household_income_change": 0.0, "population": 117045.28244465016 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 648.0, + "average_household_income_change_upper": 885.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000598", "constituency_name": "Constituency 598", - "x": 8, - "y": 19, + "x": 37, + "y": 14, "average_household_income_change": 766.7318410506416, "relative_household_income_change": 0.0, "population": 88213.62626352572 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1297.0, + "average_household_income_change_upper": 1764.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000599", "constituency_name": "Constituency 599", - "x": 9, - "y": 19, + "x": 38, + "y": 14, "average_household_income_change": 1530.454228643761, "relative_household_income_change": -0.0, "population": 138297.81002117688 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1497.0, + "average_household_income_change_upper": 2034.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000600", "constituency_name": "Constituency 600", - "x": 10, - "y": 19, + "x": 39, + "y": 14, "average_household_income_change": 1765.2977730020027, "relative_household_income_change": 0.0, "population": 139824.0752634297 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1036.0, + "average_household_income_change_upper": 1410.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000601", "constituency_name": "Constituency 601", - "x": 11, - "y": 19, + "x": 0, + "y": 15, "average_household_income_change": 1223.2611077151216, "relative_household_income_change": 1e-12, "population": 113591.50698099248 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 322.0, + "average_household_income_change_upper": 444.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000602", "constituency_name": "Constituency 602", - "x": 12, - "y": 19, + "x": 1, + "y": 15, "average_household_income_change": 382.6683234379353, "relative_household_income_change": 0.0, "population": 141310.5086029322 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 724.0, + "average_household_income_change_upper": 988.0, + "relative_household_income_change_lower": 0.6482, + "relative_household_income_change_upper": 0.878, "constituency_code": "E14000000603", "constituency_name": "Constituency 603", - "x": null, - "y": null, + "x": 2, + "y": 15, "average_household_income_change": 856.1768560114076, "relative_household_income_change": 0.7630627694108344, "population": 111736.58376417658 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -445.0, + "average_household_income_change_upper": -323.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000604", "constituency_name": "Constituency 604", - "x": 14, - "y": 19, + "x": 3, + "y": 15, "average_household_income_change": -383.73355517201844, "relative_household_income_change": 0.0, "population": 74431.454997639 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1007.0, + "average_household_income_change_upper": 1371.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000605", "constituency_name": "Constituency 605", - "x": 15, - "y": 19, + "x": 4, + "y": 15, "average_household_income_change": 1189.128223761453, "relative_household_income_change": 0.0, "population": 96831.1726712148 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1627.0, + "average_household_income_change_upper": 2209.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000606", "constituency_name": "Constituency 606", - "x": 16, - "y": 19, + "x": 5, + "y": 15, "average_household_income_change": 1918.0756490163558, "relative_household_income_change": -0.0, "population": 141947.63285895935 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -415.0, + "average_household_income_change_upper": -301.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000607", "constituency_name": "Constituency 607", - "x": 17, - "y": 19, + "x": 6, + "y": 15, "average_household_income_change": -357.7091821516629, "relative_household_income_change": 0.0, "population": 104614.10256123879 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 531.0, + "average_household_income_change_upper": 726.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000608", "constituency_name": "Constituency 608", - "x": 18, - "y": 19, + "x": 7, + "y": 15, "average_household_income_change": 628.4179318357687, "relative_household_income_change": 1e-12, "population": 103755.05655807689 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1440.0, + "average_household_income_change_upper": -1058.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000609", "constituency_name": "Constituency 609", - "x": 19, - "y": 19, + "x": 8, + "y": 15, "average_household_income_change": -1248.9217949583033, "relative_household_income_change": -0.0, "population": 147521.3750362315 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1825.0, + "average_household_income_change_upper": -1343.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000610", "constituency_name": "Constituency 610", - "x": null, - "y": null, + "x": 9, + "y": 15, "average_household_income_change": -1583.7983552457956, "relative_household_income_change": 0.0, "population": 51688.1449805309 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1125.0, + "average_household_income_change_upper": 1530.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000611", "constituency_name": "Constituency 611", - "x": 21, - "y": 19, + "x": 10, + "y": 15, "average_household_income_change": 1327.7323629112625, "relative_household_income_change": -0.0, "population": 103884.21189524661 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1674.0, + "average_household_income_change_upper": 2273.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000612", "constituency_name": "Constituency 612", - "x": 22, - "y": 19, + "x": 11, + "y": 15, "average_household_income_change": 1973.251067041317, "relative_household_income_change": 1e-12, "population": 83744.23359295663 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 67.0, + "average_household_income_change_upper": 99.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000613", "constituency_name": "Constituency 613", - "x": 23, - "y": 19, + "x": 12, + "y": 15, "average_household_income_change": 83.21324225082982, "relative_household_income_change": -0.0, "population": 68368.57302030973 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -458.0, + "average_household_income_change_upper": -332.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000614", "constituency_name": "Constituency 614", - "x": 24, - "y": 19, + "x": 13, + "y": 15, "average_household_income_change": -395.21045059080564, "relative_household_income_change": 0.0, "population": 80043.8086664578 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1108.0, + "average_household_income_change_upper": 1507.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000615", "constituency_name": "Constituency 615", - "x": 25, - "y": 19, + "x": 14, + "y": 15, "average_household_income_change": 1307.3369463042873, "relative_household_income_change": -0.0, "population": 105144.40885855842 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1479.0, + "average_household_income_change_upper": -1087.0, + "relative_household_income_change_lower": 0.3606, + "relative_household_income_change_upper": 0.489, "constituency_code": "E14000000616", "constituency_name": "Constituency 616", - "x": 26, - "y": 19, + "x": 15, + "y": 15, "average_household_income_change": -1282.621906237099, "relative_household_income_change": 0.4248049166319652, "population": 128939.8907021561 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 807.0, + "average_household_income_change_upper": 1100.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000617", "constituency_name": "Constituency 617", - "x": null, - "y": null, + "x": 16, + "y": 15, "average_household_income_change": 953.4736754873703, "relative_household_income_change": 0.0, "population": 103767.56011373061 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1073.0, + "average_household_income_change_upper": -787.0, + "relative_household_income_change_lower": 0.5754, + "relative_household_income_change_upper": 0.7795, "constituency_code": "E14000000618", "constituency_name": "Constituency 618", - "x": 28, - "y": 19, + "x": 17, + "y": 15, "average_household_income_change": -929.6915636162972, "relative_household_income_change": 0.6774768027545954, "population": 149677.9304957911 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 652.0, + "average_household_income_change_upper": 891.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000619", "constituency_name": "Constituency 619", - "x": 29, - "y": 19, + "x": 18, + "y": 15, "average_household_income_change": 771.5763025685698, "relative_household_income_change": 0.0, "population": 77227.88788830965 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -885.0, + "average_household_income_change_upper": -648.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000620", "constituency_name": "Constituency 620", - "x": 30, - "y": 19, + "x": 19, + "y": 15, "average_household_income_change": -766.5509814539178, "relative_household_income_change": -3.5e-9, "population": 82122.3677446993 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -869.0, + "average_household_income_change_upper": -637.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000621", "constituency_name": "Constituency 621", - "x": 0, - "y": 20, + "x": 20, + "y": 15, "average_household_income_change": -752.9484879757442, "relative_household_income_change": -3.5e-9, "population": 76081.76656371151 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1036.0, + "average_household_income_change_upper": 1410.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000622", "constituency_name": "Constituency 622", - "x": 1, - "y": 20, + "x": 21, + "y": 15, "average_household_income_change": 1222.9442365982281, "relative_household_income_change": 0.0, "population": 120679.12954257091 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1651.0, + "average_household_income_change_upper": -1214.0, + "relative_household_income_change_lower": 0.7811, + "relative_household_income_change_upper": 1.0579, "constituency_code": "E14000000623", "constituency_name": "Constituency 623", - "x": 2, - "y": 20, + "x": 22, + "y": 15, "average_household_income_change": -1432.8521114865923, "relative_household_income_change": 0.9195260235316269, "population": 119371.17541933047 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 451.0, + "average_household_income_change_upper": 619.0, + "relative_household_income_change_lower": 0.2833, + "relative_household_income_change_upper": 0.3843, "constituency_code": "E14000000624", "constituency_name": "Constituency 624", - "x": null, - "y": null, + "x": 23, + "y": 15, "average_household_income_change": 534.9241254942352, "relative_household_income_change": 0.3338237028975374, "population": 60955.649829977745 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1564.0, + "average_household_income_change_upper": 2124.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000625", "constituency_name": "Constituency 625", - "x": 4, - "y": 20, + "x": 24, + "y": 15, "average_household_income_change": 1843.912890268073, "relative_household_income_change": 1e-12, "population": 129358.11465348065 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 312.0, + "average_household_income_change_upper": 431.0, + "relative_household_income_change_lower": 0.1579, + "relative_household_income_change_upper": 0.2146, "constituency_code": "E14000000626", "constituency_name": "Constituency 626", - "x": 5, - "y": 20, + "x": 25, + "y": 15, "average_household_income_change": 371.6653569909436, "relative_household_income_change": 0.18625157984189045, "population": 55491.321063149466 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -380.0, + "average_household_income_change_upper": -275.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000627", "constituency_name": "Constituency 627", - "x": 6, - "y": 20, + "x": 26, + "y": 15, "average_household_income_change": -327.3032084220029, "relative_household_income_change": 1e-12, "population": 105868.16394036285 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1721.0, + "average_household_income_change_upper": -1266.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000628", "constituency_name": "Constituency 628", - "x": 7, - "y": 20, + "x": 27, + "y": 15, "average_household_income_change": -1493.6082836546789, "relative_household_income_change": 1e-12, "population": 92021.95979984169 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1958.0, + "average_household_income_change_upper": -1441.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000629", "constituency_name": "Constituency 629", - "x": 8, - "y": 20, + "x": 28, + "y": 15, "average_household_income_change": -1699.9458449766842, "relative_household_income_change": -0.0, "population": 122067.3726304384 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -522.0, + "average_household_income_change_upper": -380.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000630", "constituency_name": "Constituency 630", - "x": 9, - "y": 20, + "x": 29, + "y": 15, "average_household_income_change": -450.5896596519281, "relative_household_income_change": 1e-12, "population": 52936.33348045289 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1588.0, + "average_household_income_change_upper": -1168.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000631", "constituency_name": "Constituency 631", - "x": null, - "y": null, + "x": 30, + "y": 15, "average_household_income_change": -1377.8764440953496, "relative_household_income_change": 1e-12, "population": 123121.2733392203 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1212.0, + "average_household_income_change_upper": -890.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000632", "constituency_name": "Constituency 632", - "x": 11, - "y": 20, + "x": 31, + "y": 15, "average_household_income_change": -1051.2636353189384, "relative_household_income_change": 0.0, "population": 96399.63093497188 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 9.0, + "average_household_income_change_upper": 20.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000633", "constituency_name": "Constituency 633", - "x": 12, - "y": 20, + "x": 32, + "y": 15, "average_household_income_change": 14.147980833502327, "relative_household_income_change": -0.0, "population": 90394.5248879687 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2117.0, + "average_household_income_change_upper": -1559.0, + "relative_household_income_change_lower": 0.7402, + "relative_household_income_change_upper": 1.0024, "constituency_code": "E14000000634", "constituency_name": "Constituency 634", - "x": 13, - "y": 20, + "x": 33, + "y": 15, "average_household_income_change": -1838.0730523928687, "relative_household_income_change": 0.8712942895857928, "population": 60641.30386365898 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -104.0, + "average_household_income_change_upper": -71.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000635", "constituency_name": "Constituency 635", - "x": 14, - "y": 20, + "x": 34, + "y": 15, "average_household_income_change": -87.23716684922378, "relative_household_income_change": 0.0, "population": 104747.24642102896 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1645.0, + "average_household_income_change_upper": 2234.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000636", "constituency_name": "Constituency 636", - "x": 15, - "y": 20, + "x": 35, + "y": 15, "average_household_income_change": 1939.336244729525, "relative_household_income_change": 0.0, "population": 127050.64843988064 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 734.0, + "average_household_income_change_upper": 1002.0, + "relative_household_income_change_lower": 0.8389, + "relative_household_income_change_upper": 1.1361, "constituency_code": "E14000000637", "constituency_name": "Constituency 637", - "x": 16, - "y": 20, + "x": 36, + "y": 15, "average_household_income_change": 868.1707597779589, "relative_household_income_change": 0.987503057055717, "population": 129088.10024620766 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2058.0, + "average_household_income_change_upper": -1515.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000638", "constituency_name": "Constituency 638", - "x": null, - "y": null, + "x": 37, + "y": 15, "average_household_income_change": -1786.8649278672845, "relative_household_income_change": -0.0, "population": 123939.15459225708 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -221.0, + "average_household_income_change_upper": -157.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000639", "constituency_name": "Constituency 639", - "x": 18, - "y": 20, + "x": 38, + "y": 15, "average_household_income_change": -189.309168849606, "relative_household_income_change": -0.0, "population": 120874.07398615906 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1352.0, + "average_household_income_change_upper": 1838.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000640", "constituency_name": "Constituency 640", - "x": 19, - "y": 20, + "x": 39, + "y": 15, "average_household_income_change": 1594.94324793443, "relative_household_income_change": -0.0, "population": 74818.70120486079 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 334.0, + "average_household_income_change_upper": 460.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000641", "constituency_name": "Constituency 641", - "x": 20, - "y": 20, + "x": 0, + "y": 16, "average_household_income_change": 396.5805294054912, "relative_household_income_change": -3.5e-9, "population": 107085.6435479035 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2291.0, + "average_household_income_change_upper": -1687.0, + "relative_household_income_change_lower": 0.6342, + "relative_household_income_change_upper": 0.8591, "constituency_code": "E14000000642", "constituency_name": "Constituency 642", - "x": 21, - "y": 20, + "x": 1, + "y": 16, "average_household_income_change": -1988.8676478597636, "relative_household_income_change": 0.7466795414572786, "population": 68794.36517222921 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1632.0, + "average_household_income_change_upper": -1200.0, + "relative_household_income_change_lower": 0.4116, + "relative_household_income_change_upper": 0.558, "constituency_code": "E14000000643", "constituency_name": "Constituency 643", - "x": 22, - "y": 20, + "x": 2, + "y": 16, "average_household_income_change": -1416.3933519071054, "relative_household_income_change": 0.4848194651534483, "population": 60473.494553043936 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1058.0, + "average_household_income_change_upper": 1440.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000644", "constituency_name": "Constituency 644", - "x": 23, - "y": 20, + "x": 3, + "y": 16, "average_household_income_change": 1249.393074236446, "relative_household_income_change": -3.5e-9, "population": 123993.994089221 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 886.0, + "average_household_income_change_upper": 1207.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000645", "constituency_name": "Constituency 645", - "x": null, - "y": null, + "x": 4, + "y": 16, "average_household_income_change": 1046.8309597875534, "relative_household_income_change": -3.5e-9, "population": 82589.20612888796 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 899.0, + "average_household_income_change_upper": 1225.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000646", "constituency_name": "Constituency 646", - "x": 25, - "y": 20, + "x": 5, + "y": 16, "average_household_income_change": 1062.21099433036, "relative_household_income_change": 1e-12, "population": 123517.19941423072 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -364.0, + "average_household_income_change_upper": -263.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000647", "constituency_name": "Constituency 647", - "x": 26, - "y": 20, + "x": 6, + "y": 16, "average_household_income_change": -313.8369092416631, "relative_household_income_change": -3.5e-9, "population": 144734.81087895617 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1407.0, + "average_household_income_change_upper": 1911.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000648", "constituency_name": "Constituency 648", - "x": 27, - "y": 20, + "x": 7, + "y": 16, "average_household_income_change": 1658.9417288888621, "relative_household_income_change": 0.0, "population": 54363.82273350112 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 376.0, + "average_household_income_change_upper": 518.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "constituency_code": "E14000000649", "constituency_name": "Constituency 649", - "x": 28, - "y": 20, + "x": 8, + "y": 16, "average_household_income_change": 446.986217100954, "relative_household_income_change": 1e-12, "population": 129264.05146024606 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 267.0, + "average_household_income_change_upper": 369.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "constituency_code": "E14000000650", "constituency_name": "Constituency 650", - "x": 29, - "y": 20, + "x": 9, + "y": 16, "average_household_income_change": 317.6664618952065, "relative_household_income_change": -3.5e-9, "population": 56840.72937490232 @@ -6221,15 +9471,25 @@ ], "local_authority_impact": [ { + "disclosure_tier": "reported", + "average_household_income_change_lower": 395.0, + "average_household_income_change_upper": 542.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000001", "local_authority_name": "Local Authority 1", - "x": null, - "y": null, + "x": 0, + "y": 0, "average_household_income_change": 468.5659066798089, "relative_household_income_change": -3.5e-9, "population": 100206.37490705009 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -83.0, + "average_household_income_change_upper": -55.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000002", "local_authority_name": "Local Authority 2", "x": 1, @@ -6239,6 +9499,11 @@ "population": 123384.01709097778 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 772.0, + "average_household_income_change_upper": 1052.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000003", "local_authority_name": "Local Authority 3", "x": 2, @@ -6248,6 +9513,11 @@ "population": 141131.81544042542 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1620.0, + "average_household_income_change_upper": 2200.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000004", "local_authority_name": "Local Authority 4", "x": 3, @@ -6257,6 +9527,11 @@ "population": 76099.80320995662 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 821.0, + "average_household_income_change_upper": 1119.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000005", "local_authority_name": "Local Authority 5", "x": 4, @@ -6266,6 +9541,11 @@ "population": 72911.01548143946 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1392.0, + "average_household_income_change_upper": -1023.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000006", "local_authority_name": "Local Authority 6", "x": 5, @@ -6275,6 +9555,11 @@ "population": 100754.01422780073 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 496.0, + "average_household_income_change_upper": 680.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000007", "local_authority_name": "Local Authority 7", "x": 6, @@ -6284,15 +9569,25 @@ "population": 148197.15018666055 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2050.0, + "average_household_income_change_upper": -1509.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000008", "local_authority_name": "Local Authority 8", - "x": null, - "y": null, + "x": 7, + "y": 0, "average_household_income_change": -1779.658218255996, "relative_household_income_change": 0.0, "population": 128556.48672377989 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 177.0, + "average_household_income_change_upper": 247.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000009", "local_authority_name": "Local Authority 9", "x": 8, @@ -6302,6 +9597,11 @@ "population": 115700.32594875956 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1589.0, + "average_household_income_change_upper": -1169.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000010", "local_authority_name": "Local Authority 10", "x": 9, @@ -6311,6 +9611,11 @@ "population": 73114.63274971377 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -720.0, + "average_household_income_change_upper": -526.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000011", "local_authority_name": "Local Authority 11", "x": 10, @@ -6320,6 +9625,11 @@ "population": 139342.8006351117 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1938.0, + "average_household_income_change_upper": -1427.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000012", "local_authority_name": "Local Authority 12", "x": 11, @@ -6329,6 +9639,11 @@ "population": 132067.73320383328 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2066.0, + "average_household_income_change_upper": -1521.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000013", "local_authority_name": "Local Authority 13", "x": 12, @@ -6338,6 +9653,11 @@ "population": 104826.38130177911 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1121.0, + "average_household_income_change_upper": -823.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000014", "local_authority_name": "Local Authority 14", "x": 13, @@ -6347,15 +9667,25 @@ "population": 64127.86306468177 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1431.0, + "average_household_income_change_upper": 1944.0, + "relative_household_income_change_lower": 0.1688, + "relative_household_income_change_upper": 0.2295, "local_authority_code": "E06000000015", "local_authority_name": "Local Authority 15", - "x": null, - "y": null, + "x": 14, + "y": 0, "average_household_income_change": 1687.1904190190778, "relative_household_income_change": 0.19915732727299662, "population": 82930.81346214596 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -623.0, + "average_household_income_change_upper": -454.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000016", "local_authority_name": "Local Authority 16", "x": 15, @@ -6365,6 +9695,11 @@ "population": 102310.46935923983 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1480.0, + "average_household_income_change_upper": 2011.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000017", "local_authority_name": "Local Authority 17", "x": 16, @@ -6374,6 +9709,11 @@ "population": 110324.97681835851 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1859.0, + "average_household_income_change_upper": -1368.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000018", "local_authority_name": "Local Authority 18", "x": 17, @@ -6383,6 +9723,11 @@ "population": 88984.89875920078 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1947.0, + "average_household_income_change_upper": -1433.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000019", "local_authority_name": "Local Authority 19", "x": 18, @@ -6392,6 +9737,11 @@ "population": 72890.3783981163 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -185.0, + "average_household_income_change_upper": -131.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000020", "local_authority_name": "Local Authority 20", "x": 19, @@ -6401,6 +9751,11 @@ "population": 73478.18403793292 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 906.0, + "average_household_income_change_upper": 1234.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000021", "local_authority_name": "Local Authority 21", "x": 20, @@ -6410,15 +9765,25 @@ "population": 98666.90369402937 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1250.0, + "average_household_income_change_upper": -918.0, + "relative_household_income_change_lower": 0.7107, + "relative_household_income_change_upper": 0.9625, "local_authority_code": "E06000000022", "local_authority_name": "Local Authority 22", - "x": null, - "y": null, + "x": 21, + "y": 0, "average_household_income_change": -1084.190750741544, "relative_household_income_change": 0.836601005622632, "population": 83210.36408380332 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 883.0, + "average_household_income_change_upper": 1203.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000023", "local_authority_name": "Local Authority 23", "x": 22, @@ -6428,6 +9793,11 @@ "population": 82189.76985415467 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -414.0, + "average_household_income_change_upper": -300.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000024", "local_authority_name": "Local Authority 24", "x": 23, @@ -6437,6 +9807,11 @@ "population": 74556.76041445452 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 214.0, + "average_household_income_change_upper": 297.0, + "relative_household_income_change_lower": 0.6143, + "relative_household_income_change_upper": 0.8322, "local_authority_code": "E06000000025", "local_authority_name": "Local Authority 25", "x": 24, @@ -6446,6 +9821,11 @@ "population": 127857.36052385914 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -650.0, + "average_household_income_change_upper": -474.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000026", "local_authority_name": "Local Authority 26", "x": 25, @@ -6455,6 +9835,11 @@ "population": 106980.91731680604 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -129.0, + "average_household_income_change_upper": -89.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000027", "local_authority_name": "Local Authority 27", "x": 26, @@ -6464,6 +9849,11 @@ "population": 109578.0521407703 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1576.0, + "average_household_income_change_upper": -1159.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000028", "local_authority_name": "Local Authority 28", "x": 27, @@ -6473,15 +9863,25 @@ "population": 58898.86324268766 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 482.0, + "average_household_income_change_upper": 660.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000029", "local_authority_name": "Local Authority 29", - "x": null, - "y": null, + "x": 28, + "y": 0, "average_household_income_change": 570.918963320222, "relative_household_income_change": 0.0, "population": 125338.41108172822 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -319.0, + "average_household_income_change_upper": -230.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000030", "local_authority_name": "Local Authority 30", "x": 29, @@ -6491,6 +9891,11 @@ "population": 64611.39147086428 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 611.0, + "average_household_income_change_upper": 835.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000031", "local_authority_name": "Local Authority 31", "x": 30, @@ -6500,2962 +9905,4607 @@ "population": 110356.63768942235 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -612.0, + "average_household_income_change_upper": -446.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000032", "local_authority_name": "Local Authority 32", - "x": 0, - "y": 1, + "x": 31, + "y": 0, "average_household_income_change": -528.8110657531159, "relative_household_income_change": -3.5e-9, "population": 105587.30047688412 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2187.0, + "average_household_income_change_upper": -1611.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000033", "local_authority_name": "Local Authority 33", - "x": 1, - "y": 1, + "x": 32, + "y": 0, "average_household_income_change": -1899.0443795165293, "relative_household_income_change": 1e-12, "population": 52173.26085032475 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1094.0, + "average_household_income_change_upper": -803.0, + "relative_household_income_change_lower": 0.7322, + "relative_household_income_change_upper": 0.9917, "local_authority_code": "E06000000034", "local_authority_name": "Local Authority 34", - "x": 2, - "y": 1, + "x": 33, + "y": 0, "average_household_income_change": -948.6511027706763, "relative_household_income_change": 0.861974368839776, "population": 108831.07934043322 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 504.0, + "average_household_income_change_upper": 690.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000035", "local_authority_name": "Local Authority 35", - "x": 3, - "y": 1, + "x": 34, + "y": 0, "average_household_income_change": 596.6157268316401, "relative_household_income_change": 0.0, "population": 102116.27736033645 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 173.0, + "average_household_income_change_upper": 243.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000036", "local_authority_name": "Local Authority 36", - "x": null, - "y": null, + "x": 35, + "y": 0, "average_household_income_change": 207.93735297858885, "relative_household_income_change": 0.0, "population": 116564.66422229337 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -118.0, + "average_household_income_change_upper": -81.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000037", "local_authority_name": "Local Authority 37", - "x": 5, - "y": 1, + "x": 36, + "y": 0, "average_household_income_change": -99.3150549349906, "relative_household_income_change": 1e-12, "population": 101593.40434133449 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -448.0, + "average_household_income_change_upper": -325.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000038", "local_authority_name": "Local Authority 38", - "x": 6, - "y": 1, + "x": 37, + "y": 0, "average_household_income_change": -386.4531583157946, "relative_household_income_change": 0.0, "population": 141472.1770422101 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 277.0, + "average_household_income_change_upper": 383.0, + "relative_household_income_change_lower": 0.0439, + "relative_household_income_change_upper": 0.0604, "local_authority_code": "E06000000039", "local_authority_name": "Local Authority 39", - "x": 7, - "y": 1, + "x": 38, + "y": 0, "average_household_income_change": 330.0627465742383, "relative_household_income_change": 0.05215779997016934, "population": 141852.21151584186 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1307.0, + "average_household_income_change_upper": -960.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000040", "local_authority_name": "Local Authority 40", - "x": 8, - "y": 1, + "x": 39, + "y": 0, "average_household_income_change": -1133.1155149702777, "relative_household_income_change": 0.0, "population": 90600.31790542022 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -969.0, + "average_household_income_change_upper": -710.0, + "relative_household_income_change_lower": 0.115, + "relative_household_income_change_upper": 0.1567, "local_authority_code": "E06000000041", "local_authority_name": "Local Authority 41", - "x": 9, + "x": 0, "y": 1, "average_household_income_change": -839.5358563959885, "relative_household_income_change": 0.13584478762195062, "population": 88141.137590001 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1344.0, + "average_household_income_change_upper": -987.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000042", "local_authority_name": "Local Authority 42", - "x": 10, + "x": 1, "y": 1, "average_household_income_change": -1165.481582988086, "relative_household_income_change": -3.5e-9, "population": 73433.4816768881 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -433.0, + "average_household_income_change_upper": -314.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000043", "local_authority_name": "Local Authority 43", - "x": null, - "y": null, + "x": 2, + "y": 1, "average_household_income_change": -373.5052701686232, "relative_household_income_change": -3.5e-9, "population": 79419.4013814455 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1338.0, + "average_household_income_change_upper": -983.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000044", "local_authority_name": "Local Authority 44", - "x": 12, + "x": 3, "y": 1, "average_household_income_change": -1160.1562975452566, "relative_household_income_change": -3.5e-9, "population": 85899.06902370857 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 641.0, + "average_household_income_change_upper": 876.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000045", "local_authority_name": "Local Authority 45", - "x": 13, + "x": 4, "y": 1, "average_household_income_change": 758.790098109745, "relative_household_income_change": -3.5e-9, "population": 63063.28444224117 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1188.0, + "average_household_income_change_upper": -872.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000046", "local_authority_name": "Local Authority 46", - "x": 14, + "x": 5, "y": 1, "average_household_income_change": -1029.720675973574, "relative_household_income_change": -0.0, "population": 104506.66479956872 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -597.0, + "average_household_income_change_upper": -435.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000047", "local_authority_name": "Local Authority 47", - "x": 15, + "x": 6, "y": 1, "average_household_income_change": -516.1419060292656, "relative_household_income_change": -3.5e-9, "population": 72118.73988243361 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1505.0, + "average_household_income_change_upper": -1107.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000048", "local_authority_name": "Local Authority 48", - "x": 16, + "x": 7, "y": 1, "average_household_income_change": -1305.9071114052686, "relative_household_income_change": 0.0, "population": 83866.0479801991 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 282.0, + "average_household_income_change_upper": 390.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000049", "local_authority_name": "Local Authority 49", - "x": 17, + "x": 8, "y": 1, "average_household_income_change": 336.04631080583937, "relative_household_income_change": -3.5e-9, "population": 143454.1556254162 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1982.0, + "average_household_income_change_upper": -1459.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000050", "local_authority_name": "Local Authority 50", - "x": null, - "y": null, + "x": 9, + "y": 1, "average_household_income_change": -1720.7042384504155, "relative_household_income_change": 0.0, "population": 60713.73636327979 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1216.0, + "average_household_income_change_upper": 1653.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000051", "local_authority_name": "Local Authority 51", - "x": 19, + "x": 10, "y": 1, "average_household_income_change": 1434.7514320345974, "relative_household_income_change": -3.5e-9, "population": 81076.07543203316 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 309.0, + "average_household_income_change_upper": 427.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000052", "local_authority_name": "Local Authority 52", - "x": 20, + "x": 11, "y": 1, "average_household_income_change": 367.94847637922425, "relative_household_income_change": 0.0, "population": 57169.51240739431 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -158.0, + "average_household_income_change_upper": -111.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000053", "local_authority_name": "Local Authority 53", - "x": 21, + "x": 12, "y": 1, "average_household_income_change": -134.27102983545325, "relative_household_income_change": -3.5e-9, "population": 112260.18240877741 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1507.0, + "average_household_income_change_upper": -1108.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000054", "local_authority_name": "Local Authority 54", - "x": 22, + "x": 13, "y": 1, "average_household_income_change": -1307.3113454330883, "relative_household_income_change": 1e-12, "population": 129712.91555041495 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1440.0, + "average_household_income_change_upper": 1956.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000055", "local_authority_name": "Local Authority 55", - "x": 23, + "x": 14, "y": 1, "average_household_income_change": 1698.0689767657648, "relative_household_income_change": 0.0, "population": 56651.85897650984 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -791.0, + "average_household_income_change_upper": -578.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000056", "local_authority_name": "Local Authority 56", - "x": 24, + "x": 15, "y": 1, "average_household_income_change": -684.5442861532538, "relative_household_income_change": 0.0, "population": 51961.63664619178 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1129.0, + "average_household_income_change_upper": -828.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000057", "local_authority_name": "Local Authority 57", - "x": null, - "y": null, + "x": 16, + "y": 1, "average_household_income_change": -978.7187202471532, "relative_household_income_change": -3.5e-9, "population": 97885.13067633384 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1502.0, + "average_household_income_change_upper": 2040.0, + "relative_household_income_change_lower": 0.3414, + "relative_household_income_change_upper": 0.463, "local_authority_code": "E06000000058", "local_authority_name": "Local Authority 58", - "x": 26, + "x": 17, "y": 1, "average_household_income_change": 1770.8198408401636, "relative_household_income_change": 0.4022130639378835, "population": 116710.80210949371 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -265.0, + "average_household_income_change_upper": -190.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000059", "local_authority_name": "Local Authority 59", - "x": 27, + "x": 18, "y": 1, "average_household_income_change": -227.21024178206807, "relative_household_income_change": 0.0, "population": 148868.47538700586 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 342.0, + "average_household_income_change_upper": 471.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000060", "local_authority_name": "Local Authority 60", - "x": 28, + "x": 19, "y": 1, "average_household_income_change": 406.1970125350696, "relative_household_income_change": -0.0, "population": 69260.03618381429 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -90.0, + "average_household_income_change_upper": -60.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000061", "local_authority_name": "Local Authority 61", - "x": 29, + "x": 20, "y": 1, "average_household_income_change": -74.91285483834443, "relative_household_income_change": 1e-12, "population": 142780.21598839073 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1544.0, + "average_household_income_change_upper": -1135.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000062", "local_authority_name": "Local Authority 62", - "x": 30, + "x": 21, "y": 1, "average_household_income_change": -1339.6500922702558, "relative_household_income_change": -0.0, "population": 99488.94447212561 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1496.0, + "average_household_income_change_upper": -1100.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000063", "local_authority_name": "Local Authority 63", - "x": 0, - "y": 2, + "x": 22, + "y": 1, "average_household_income_change": -1298.0181141563762, "relative_household_income_change": -3.5e-9, "population": 55030.787258256365 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -66.0, + "average_household_income_change_upper": -43.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000064", "local_authority_name": "Local Authority 64", - "x": null, - "y": null, + "x": 23, + "y": 1, "average_household_income_change": -54.75236674179662, "relative_household_income_change": -0.0, "population": 138632.11137234024 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2126.0, + "average_household_income_change_upper": -1565.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000065", "local_authority_name": "Local Authority 65", - "x": 2, - "y": 2, + "x": 24, + "y": 1, "average_household_income_change": -1845.58137993721, "relative_household_income_change": -3.5e-9, "population": 60267.99379230523 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -607.0, + "average_household_income_change_upper": -442.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000066", "local_authority_name": "Local Authority 66", - "x": 3, - "y": 2, + "x": 25, + "y": 1, "average_household_income_change": -524.38253465656, "relative_household_income_change": -0.0, "population": 102857.31969449116 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1201.0, + "average_household_income_change_upper": 1633.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000067", "local_authority_name": "Local Authority 67", - "x": 4, - "y": 2, + "x": 26, + "y": 1, "average_household_income_change": 1417.0468691939302, "relative_household_income_change": 1e-12, "population": 60692.56747606022 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -774.0, + "average_household_income_change_upper": -566.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000068", "local_authority_name": "Local Authority 68", - "x": 5, - "y": 2, + "x": 27, + "y": 1, "average_household_income_change": -669.6944521175205, "relative_household_income_change": 1e-12, "population": 62205.01230755512 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -805.0, + "average_household_income_change_upper": -589.0, + "relative_household_income_change_lower": 0.2765, + "relative_household_income_change_upper": 0.3751, "local_authority_code": "E06000000069", "local_authority_name": "Local Authority 69", - "x": 6, - "y": 2, + "x": 28, + "y": 1, "average_household_income_change": -697.2904833598284, "relative_household_income_change": 0.32579234099818233, "population": 50703.25977836838 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1398.0, + "average_household_income_change_upper": 1900.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000070", "local_authority_name": "Local Authority 70", - "x": 7, - "y": 2, + "x": 29, + "y": 1, "average_household_income_change": 1648.9893529969822, "relative_household_income_change": 1e-12, "population": 111110.29655746072 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1404.0, + "average_household_income_change_upper": 1907.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000071", "local_authority_name": "Local Authority 71", - "x": null, - "y": null, + "x": 30, + "y": 1, "average_household_income_change": 1655.3433374587626, "relative_household_income_change": 1e-12, "population": 107139.20307198947 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 979.0, + "average_household_income_change_upper": 1333.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000072", "local_authority_name": "Local Authority 72", - "x": 9, - "y": 2, + "x": 31, + "y": 1, "average_household_income_change": 1155.7966124245381, "relative_household_income_change": -3.5e-9, "population": 77257.50624124409 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -854.0, + "average_household_income_change_upper": -625.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000073", "local_authority_name": "Local Authority 73", - "x": 10, - "y": 2, + "x": 32, + "y": 1, "average_household_income_change": -739.4421680729821, "relative_household_income_change": 1e-12, "population": 147255.8211176183 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2161.0, + "average_household_income_change_upper": -1591.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000074", "local_authority_name": "Local Authority 74", - "x": 11, - "y": 2, + "x": 33, + "y": 1, "average_household_income_change": -1876.352256872487, "relative_household_income_change": -0.0, "population": 112467.48919408373 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -737.0, + "average_household_income_change_upper": -539.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000075", "local_authority_name": "Local Authority 75", - "x": 12, - "y": 2, + "x": 34, + "y": 1, "average_household_income_change": -637.7726449765091, "relative_household_income_change": -0.0, "population": 143837.46577293432 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 33.0, + "average_household_income_change_upper": 53.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000076", "local_authority_name": "Local Authority 76", - "x": 13, - "y": 2, + "x": 35, + "y": 1, "average_household_income_change": 42.97080220967642, "relative_household_income_change": -0.0, "population": 136007.1936987238 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 206.0, + "average_household_income_change_upper": 287.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000077", "local_authority_name": "Local Authority 77", - "x": 14, - "y": 2, + "x": 36, + "y": 1, "average_household_income_change": 246.10490045053135, "relative_household_income_change": -3.5e-9, "population": 112922.3491300438 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1179.0, + "average_household_income_change_upper": 1603.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000078", "local_authority_name": "Local Authority 78", - "x": null, - "y": null, + "x": 37, + "y": 1, "average_household_income_change": 1391.1407839376343, "relative_household_income_change": -0.0, "population": 97785.16806463109 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1818.0, + "average_household_income_change_upper": -1338.0, + "relative_household_income_change_lower": 0.7134, + "relative_household_income_change_upper": 0.9662, "local_authority_code": "E06000000079", "local_authority_name": "Local Authority 79", - "x": 16, - "y": 2, + "x": 38, + "y": 1, "average_household_income_change": -1577.6611573462892, "relative_household_income_change": 0.8397807712331508, "population": 61633.32002388823 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1375.0, + "average_household_income_change_upper": 1869.0, + "relative_household_income_change_lower": 0.3802, + "relative_household_income_change_upper": 0.5155, "local_authority_code": "E06000000080", "local_authority_name": "Local Authority 80", - "x": 17, - "y": 2, + "x": 39, + "y": 1, "average_household_income_change": 1621.9689662911376, "relative_household_income_change": 0.44786982566411027, "population": 141121.86104609378 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1563.0, + "average_household_income_change_upper": 2123.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000081", "local_authority_name": "Local Authority 81", - "x": 18, + "x": 0, "y": 2, "average_household_income_change": 1843.023545552603, "relative_household_income_change": 1e-12, "population": 100289.83457812988 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1815.0, + "average_household_income_change_upper": -1335.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000082", "local_authority_name": "Local Authority 82", - "x": 19, + "x": 1, "y": 2, "average_household_income_change": -1574.802477133047, "relative_household_income_change": -0.0, "population": 107128.20337032386 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1442.0, + "average_household_income_change_upper": 1959.0, + "relative_household_income_change_lower": 0.7039, + "relative_household_income_change_upper": 0.9535, "local_authority_code": "E06000000083", "local_authority_name": "Local Authority 83", - "x": 20, + "x": 2, "y": 2, "average_household_income_change": 1700.2682547782751, "relative_household_income_change": 0.8286985245589028, "population": 130406.17866850618 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -130.0, + "average_household_income_change_upper": -90.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000084", "local_authority_name": "Local Authority 84", - "x": 21, + "x": 3, "y": 2, "average_household_income_change": -109.59877514069305, "relative_household_income_change": 1e-12, "population": 143330.49244997563 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 536.0, + "average_household_income_change_upper": 733.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000085", "local_authority_name": "Local Authority 85", - "x": null, - "y": null, + "x": 4, + "y": 2, "average_household_income_change": 634.4029721965949, "relative_household_income_change": -3.5e-9, "population": 84288.84699659675 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 250.0, + "average_household_income_change_upper": 347.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000086", "local_authority_name": "Local Authority 86", - "x": 23, + "x": 5, "y": 2, "average_household_income_change": 298.3073319458458, "relative_household_income_change": -0.0, "population": 112072.35527186739 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 985.0, + "average_household_income_change_upper": 1341.0, + "relative_household_income_change_lower": 0.7421, + "relative_household_income_change_upper": 1.0051, "local_authority_code": "E06000000087", "local_authority_name": "Local Authority 87", - "x": 24, + "x": 6, "y": 2, "average_household_income_change": 1162.613580175742, "relative_household_income_change": 0.8735826901840349, "population": 53334.41826254266 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1076.0, + "average_household_income_change_upper": 1463.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000088", "local_authority_name": "Local Authority 88", - "x": 25, + "x": 7, "y": 2, "average_household_income_change": 1269.5038134454041, "relative_household_income_change": -0.0, "population": 146967.75846424367 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 797.0, + "average_household_income_change_upper": 1086.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000089", "local_authority_name": "Local Authority 89", - "x": 26, + "x": 8, "y": 2, "average_household_income_change": 941.4584916929489, "relative_household_income_change": 0.0, "population": 99442.99104208047 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -147.0, + "average_household_income_change_upper": -102.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000090", "local_authority_name": "Local Authority 90", - "x": 27, + "x": 9, "y": 2, "average_household_income_change": -124.6417436565066, "relative_household_income_change": 0.0, "population": 80384.06129506999 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -154.0, + "average_household_income_change_upper": -108.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000091", "local_authority_name": "Local Authority 91", - "x": 28, + "x": 10, "y": 2, "average_household_income_change": -131.22312675529324, "relative_household_income_change": 0.0, "population": 95193.73712188863 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1378.0, + "average_household_income_change_upper": 1873.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000092", "local_authority_name": "Local Authority 92", - "x": null, - "y": null, + "x": 11, + "y": 2, "average_household_income_change": 1625.5843992648, "relative_household_income_change": -0.0, "population": 141357.51829077472 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 358.0, + "average_household_income_change_upper": 492.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000093", "local_authority_name": "Local Authority 93", - "x": 30, + "x": 12, "y": 2, "average_household_income_change": 424.8513763312842, "relative_household_income_change": -3.5e-9, "population": 119300.97621805023 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1074.0, + "average_household_income_change_upper": 1462.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000094", "local_authority_name": "Local Authority 94", - "x": 0, - "y": 3, + "x": 13, + "y": 2, "average_household_income_change": 1268.131101085673, "relative_household_income_change": -0.0, "population": 149013.66364449463 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1764.0, + "average_household_income_change_upper": -1298.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000095", "local_authority_name": "Local Authority 95", - "x": 1, - "y": 3, + "x": 14, + "y": 2, "average_household_income_change": -1530.9993636045124, "relative_household_income_change": 0.0, "population": 116664.05867270652 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2245.0, + "average_household_income_change_upper": -1653.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000096", "local_authority_name": "Local Authority 96", - "x": 2, - "y": 3, + "x": 15, + "y": 2, "average_household_income_change": -1949.1910457980314, "relative_household_income_change": 0.0, "population": 112806.71261334998 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 456.0, + "average_household_income_change_upper": 625.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000097", "local_authority_name": "Local Authority 97", - "x": 3, - "y": 3, + "x": 16, + "y": 2, "average_household_income_change": 540.3517666893554, "relative_household_income_change": -0.0, "population": 59479.33774122507 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 74.0, + "average_household_income_change_upper": 108.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000098", "local_authority_name": "Local Authority 98", - "x": 4, - "y": 3, + "x": 17, + "y": 2, "average_household_income_change": 91.25874221760988, "relative_household_income_change": 0.0, "population": 66358.81394243162 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 217.0, + "average_household_income_change_upper": 301.0, + "relative_household_income_change_lower": 0.1985, + "relative_household_income_change_upper": 0.2696, "local_authority_code": "E06000000099", "local_authority_name": "Local Authority 99", - "x": null, - "y": null, + "x": 18, + "y": 2, "average_household_income_change": 259.0652403777276, "relative_household_income_change": 0.2340381841126019, "population": 75494.00711254979 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1436.0, + "average_household_income_change_upper": -1056.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000100", "local_authority_name": "Local Authority 100", - "x": 6, - "y": 3, + "x": 19, + "y": 2, "average_household_income_change": -1245.9852723180682, "relative_household_income_change": -0.0, "population": 92066.49441666392 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1820.0, + "average_household_income_change_upper": -1339.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000101", "local_authority_name": "Local Authority 101", - "x": 7, - "y": 3, + "x": 20, + "y": 2, "average_household_income_change": -1579.4667973681244, "relative_household_income_change": -3.5e-9, "population": 100521.50805462364 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -445.0, + "average_household_income_change_upper": -323.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000102", "local_authority_name": "Local Authority 102", - "x": 8, - "y": 3, + "x": 21, + "y": 2, "average_household_income_change": -384.01290422337195, "relative_household_income_change": 1e-12, "population": 114009.55195049211 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 104.0, + "average_household_income_change_upper": 149.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000103", "local_authority_name": "Local Authority 103", - "x": 9, - "y": 3, + "x": 22, + "y": 2, "average_household_income_change": 126.69515084896732, "relative_household_income_change": 1e-12, "population": 127439.82030562818 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1500.0, + "average_household_income_change_upper": -1103.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000104", "local_authority_name": "Local Authority 104", - "x": 10, - "y": 3, + "x": 23, + "y": 2, "average_household_income_change": -1301.7351067368743, "relative_household_income_change": -0.0, "population": 53563.63516660295 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1980.0, + "average_household_income_change_upper": -1458.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000105", "local_authority_name": "Local Authority 105", - "x": 11, - "y": 3, + "x": 24, + "y": 2, "average_household_income_change": -1718.9549225276987, "relative_household_income_change": 0.0, "population": 82068.41317409082 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1994.0, + "average_household_income_change_upper": -1468.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000106", "local_authority_name": "Local Authority 106", - "x": null, - "y": null, + "x": 25, + "y": 2, "average_household_income_change": -1730.7507441245823, "relative_household_income_change": 1e-12, "population": 119226.4080348876 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 196.0, + "average_household_income_change_upper": 273.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000107", "local_authority_name": "Local Authority 107", - "x": 13, - "y": 3, + "x": 26, + "y": 2, "average_household_income_change": 234.4235660991254, "relative_household_income_change": 1e-12, "population": 140046.32145941973 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 486.0, + "average_household_income_change_upper": 665.0, + "relative_household_income_change_lower": 0.3013, + "relative_household_income_change_upper": 0.4087, "local_authority_code": "E06000000108", "local_authority_name": "Local Authority 108", - "x": 14, - "y": 3, + "x": 27, + "y": 2, "average_household_income_change": 575.5706432427354, "relative_household_income_change": 0.3550402964498325, "population": 106380.05723099297 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1030.0, + "average_household_income_change_upper": -755.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000109", "local_authority_name": "Local Authority 109", - "x": 15, - "y": 3, + "x": 28, + "y": 2, "average_household_income_change": -892.4872885317292, "relative_household_income_change": -3.5e-9, "population": 53076.74660716412 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1084.0, + "average_household_income_change_upper": -795.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000110", "local_authority_name": "Local Authority 110", - "x": 16, - "y": 3, + "x": 29, + "y": 2, "average_household_income_change": -939.2971206471809, "relative_household_income_change": -3.5e-9, "population": 104433.28349564088 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 310.0, + "average_household_income_change_upper": 428.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000111", "local_authority_name": "Local Authority 111", - "x": 17, - "y": 3, + "x": 30, + "y": 2, "average_household_income_change": 369.1730214135318, "relative_household_income_change": 1e-12, "population": 111068.85198051632 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2281.0, + "average_household_income_change_upper": -1680.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000112", "local_authority_name": "Local Authority 112", - "x": 18, - "y": 3, + "x": 31, + "y": 2, "average_household_income_change": -1980.1767990273622, "relative_household_income_change": -3.5e-9, "population": 149422.4455146322 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1942.0, + "average_household_income_change_upper": -1429.0, + "relative_household_income_change_lower": 0.5246, + "relative_household_income_change_upper": 0.7109, "local_authority_code": "E06000000113", "local_authority_name": "Local Authority 113", - "x": null, - "y": null, + "x": 32, + "y": 2, "average_household_income_change": -1685.3969016955593, "relative_household_income_change": 0.6177421413249037, "population": 73272.14262762116 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 726.0, + "average_household_income_change_upper": 990.0, + "relative_household_income_change_lower": 0.3025, + "relative_household_income_change_upper": 0.4103, "local_authority_code": "E06000000114", "local_authority_name": "Local Authority 114", - "x": 20, - "y": 3, + "x": 33, + "y": 2, "average_household_income_change": 857.8109681964099, "relative_household_income_change": 0.356428163358969, "population": 51232.78052923555 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1654.0, + "average_household_income_change_upper": 2247.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000115", "local_authority_name": "Local Authority 115", - "x": 21, - "y": 3, + "x": 34, + "y": 2, "average_household_income_change": 1950.5758188370517, "relative_household_income_change": 1e-12, "population": 76115.71228923349 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1338.0, + "average_household_income_change_upper": -983.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000116", "local_authority_name": "Local Authority 116", - "x": 22, - "y": 3, + "x": 35, + "y": 2, "average_household_income_change": -1160.224065252241, "relative_household_income_change": -0.0, "population": 89923.61812913681 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1157.0, + "average_household_income_change_upper": -849.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000117", "local_authority_name": "Local Authority 117", - "x": 23, - "y": 3, + "x": 36, + "y": 2, "average_household_income_change": -1003.0071129789468, "relative_household_income_change": -0.0, "population": 55023.62428393849 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -769.0, + "average_household_income_change_upper": -562.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000118", "local_authority_name": "Local Authority 118", - "x": 24, - "y": 3, + "x": 37, + "y": 2, "average_household_income_change": -665.3379484000661, "relative_household_income_change": -3.5e-9, "population": 95601.70751376684 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1344.0, + "average_household_income_change_upper": -988.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000119", "local_authority_name": "Local Authority 119", - "x": 25, - "y": 3, + "x": 38, + "y": 2, "average_household_income_change": -1166.075660949811, "relative_household_income_change": -0.0, "population": 103266.09447574029 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1578.0, + "average_household_income_change_upper": 2143.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000120", "local_authority_name": "Local Authority 120", - "x": null, - "y": null, + "x": 39, + "y": 2, "average_household_income_change": 1860.735460695461, "relative_household_income_change": -0.0, "population": 131775.97595322173 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1340.0, + "average_household_income_change_upper": 1821.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000121", "local_authority_name": "Local Authority 121", - "x": 27, + "x": 0, "y": 3, "average_household_income_change": 1580.0608324809184, "relative_household_income_change": 0.0, "population": 78812.01228506769 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1514.0, + "average_household_income_change_upper": 2056.0, + "relative_household_income_change_lower": 0.0456, + "relative_household_income_change_upper": 0.0627, "local_authority_code": "E06000000122", "local_authority_name": "Local Authority 122", - "x": 28, + "x": 1, "y": 3, "average_household_income_change": 1785.1647270269898, "relative_household_income_change": 0.05415064813031445, "population": 113159.23111465383 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1301.0, + "average_household_income_change_upper": 1768.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000123", "local_authority_name": "Local Authority 123", - "x": 29, + "x": 2, "y": 3, "average_household_income_change": 1534.2332923215959, "relative_household_income_change": 0.0, "population": 141819.93936978036 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1333.0, + "average_household_income_change_upper": 1812.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000124", "local_authority_name": "Local Authority 124", - "x": 30, + "x": 3, "y": 3, "average_household_income_change": 1572.181832031823, "relative_household_income_change": 1e-12, "population": 86350.96665343967 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1850.0, + "average_household_income_change_upper": -1361.0, + "relative_household_income_change_lower": 0.53, + "relative_household_income_change_upper": 0.7181, "local_authority_code": "E06000000125", "local_authority_name": "Local Authority 125", - "x": 0, - "y": 4, + "x": 4, + "y": 3, "average_household_income_change": -1605.8392299157967, "relative_household_income_change": 0.6240767613094547, "population": 144735.63572544855 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 167.0, + "average_household_income_change_upper": 234.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000126", "local_authority_name": "Local Authority 126", - "x": 1, - "y": 4, + "x": 5, + "y": 3, "average_household_income_change": 200.19623482877958, "relative_household_income_change": -3.5e-9, "population": 51753.161581743465 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1013.0, + "average_household_income_change_upper": 1379.0, + "relative_household_income_change_lower": 0.4594, + "relative_household_income_change_upper": 0.6225, "local_authority_code": "E06000000127", "local_authority_name": "Local Authority 127", - "x": null, - "y": null, + "x": 6, + "y": 3, "average_household_income_change": 1195.8534047937524, "relative_household_income_change": 0.5409499896423211, "population": 129532.97590453389 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1886.0, + "average_household_income_change_upper": -1388.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000128", "local_authority_name": "Local Authority 128", - "x": 3, - "y": 4, + "x": 7, + "y": 3, "average_household_income_change": -1637.0444378301024, "relative_household_income_change": -0.0, "population": 53457.8999979193 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 749.0, + "average_household_income_change_upper": 1022.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000129", "local_authority_name": "Local Authority 129", - "x": 4, - "y": 4, + "x": 8, + "y": 3, "average_household_income_change": 885.2623772470519, "relative_household_income_change": -3.5e-9, "population": 123118.85395640823 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 858.0, + "average_household_income_change_upper": 1169.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000130", "local_authority_name": "Local Authority 130", - "x": 5, - "y": 4, + "x": 9, + "y": 3, "average_household_income_change": 1013.1201826824805, "relative_household_income_change": -3.5e-9, "population": 144611.15210387774 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 785.0, + "average_household_income_change_upper": 1070.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000131", "local_authority_name": "Local Authority 131", - "x": 6, - "y": 4, + "x": 10, + "y": 3, "average_household_income_change": 927.1284242260824, "relative_household_income_change": -0.0, "population": 113506.86780643024 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 841.0, + "average_household_income_change_upper": 1146.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000132", "local_authority_name": "Local Authority 132", - "x": 7, - "y": 4, + "x": 11, + "y": 3, "average_household_income_change": 993.180340538264, "relative_household_income_change": -3.5e-9, "population": 147590.3661673587 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2029.0, + "average_household_income_change_upper": -1494.0, + "relative_household_income_change_lower": 0.6974, + "relative_household_income_change_upper": 0.9446, "local_authority_code": "E06000000133", "local_authority_name": "Local Authority 133", - "x": 8, - "y": 4, + "x": 12, + "y": 3, "average_household_income_change": -1761.3207876794465, "relative_household_income_change": 0.8210333940864938, "population": 64270.32513059139 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1933.0, + "average_household_income_change_upper": -1423.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000134", "local_authority_name": "Local Authority 134", - "x": null, - "y": null, + "x": 13, + "y": 3, "average_household_income_change": -1677.9391875677602, "relative_household_income_change": 1e-12, "population": 102291.43195281904 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -611.0, + "average_household_income_change_upper": -446.0, + "relative_household_income_change_lower": 0.1973, + "relative_household_income_change_upper": 0.268, "local_authority_code": "E06000000135", "local_authority_name": "Local Authority 135", - "x": 10, - "y": 4, + "x": 14, + "y": 3, "average_household_income_change": -528.5457385095484, "relative_household_income_change": 0.23264565175973118, "population": 78181.4017919634 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2085.0, + "average_household_income_change_upper": -1535.0, + "relative_household_income_change_lower": 0.8109, + "relative_household_income_change_upper": 1.0982, "local_authority_code": "E06000000136", "local_authority_name": "Local Authority 136", - "x": 11, - "y": 4, + "x": 15, + "y": 3, "average_household_income_change": -1809.900053696826, "relative_household_income_change": 0.954552658406543, "population": 103838.26790233378 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 146.0, + "average_household_income_change_upper": 205.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000137", "local_authority_name": "Local Authority 137", - "x": 12, - "y": 4, + "x": 16, + "y": 3, "average_household_income_change": 175.59819725277003, "relative_household_income_change": 1e-12, "population": 114961.59077385967 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1952.0, + "average_household_income_change_upper": -1437.0, + "relative_household_income_change_lower": 0.0668, + "relative_household_income_change_upper": 0.0914, "local_authority_code": "E06000000138", "local_authority_name": "Local Authority 138", - "x": 13, - "y": 4, + "x": 17, + "y": 3, "average_household_income_change": -1694.1470757332215, "relative_household_income_change": 0.07908142310991939, "population": 66771.1061948385 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1537.0, + "average_household_income_change_upper": 2087.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000139", "local_authority_name": "Local Authority 139", - "x": 14, - "y": 4, + "x": 18, + "y": 3, "average_household_income_change": 1812.1023900051255, "relative_household_income_change": -3.5e-9, "population": 107198.30332877951 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1773.0, + "average_household_income_change_upper": -1304.0, + "relative_household_income_change_lower": 0.0317, + "relative_household_income_change_upper": 0.044, "local_authority_code": "E06000000140", "local_authority_name": "Local Authority 140", - "x": 15, - "y": 4, + "x": 19, + "y": 3, "average_household_income_change": -1538.359829800179, "relative_household_income_change": 0.03787036997739923, "population": 116807.61139000079 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1093.0, + "average_household_income_change_upper": 1486.0, + "relative_household_income_change_lower": 0.2952, + "relative_household_income_change_upper": 0.4004, "local_authority_code": "E06000000141", "local_authority_name": "Local Authority 141", - "x": null, - "y": null, + "x": 20, + "y": 3, "average_household_income_change": 1289.5074707439016, "relative_household_income_change": 0.3477828575349734, "population": 55879.49043451492 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -594.0, + "average_household_income_change_upper": -433.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000142", "local_authority_name": "Local Authority 142", - "x": 17, - "y": 4, + "x": 21, + "y": 3, "average_household_income_change": -513.751954512872, "relative_household_income_change": -0.0, "population": 62397.385318745444 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1392.0, + "average_household_income_change_upper": -1022.0, + "relative_household_income_change_lower": 0.3668, + "relative_household_income_change_upper": 0.4974, "local_authority_code": "E06000000143", "local_authority_name": "Local Authority 143", - "x": 18, - "y": 4, + "x": 22, + "y": 3, "average_household_income_change": -1207.0547909465174, "relative_household_income_change": 0.4321149063234381, "population": 132599.14340379782 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1346.0, + "average_household_income_change_upper": -989.0, + "relative_household_income_change_lower": 0.0979, + "relative_household_income_change_upper": 0.1335, "local_authority_code": "E06000000144", "local_authority_name": "Local Authority 144", - "x": 19, - "y": 4, + "x": 23, + "y": 3, "average_household_income_change": -1167.7105392959963, "relative_household_income_change": 0.11568502973031569, "population": 129009.39626547943 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -714.0, + "average_household_income_change_upper": -521.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000145", "local_authority_name": "Local Authority 145", - "x": 20, - "y": 4, + "x": 24, + "y": 3, "average_household_income_change": -617.4303141086494, "relative_household_income_change": 0.0, "population": 135302.34238248182 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1513.0, + "average_household_income_change_upper": -1112.0, + "relative_household_income_change_lower": 0.227, + "relative_household_income_change_upper": 0.3082, "local_authority_code": "E06000000146", "local_authority_name": "Local Authority 146", - "x": 21, - "y": 4, + "x": 25, + "y": 3, "average_household_income_change": -1312.877126927524, "relative_household_income_change": 0.26760164671532716, "population": 108770.59369107803 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1543.0, + "average_household_income_change_upper": -1134.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000147", "local_authority_name": "Local Authority 147", - "x": 22, - "y": 4, + "x": 26, + "y": 3, "average_household_income_change": -1338.488585593179, "relative_household_income_change": 1e-12, "population": 128892.88998607552 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 460.0, + "average_household_income_change_upper": 631.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000148", "local_authority_name": "Local Authority 148", - "x": null, - "y": null, + "x": 27, + "y": 3, "average_household_income_change": 545.2771172295146, "relative_household_income_change": -0.0, "population": 87531.96012899585 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1028.0, + "average_household_income_change_upper": 1399.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000149", "local_authority_name": "Local Authority 149", - "x": 24, - "y": 4, + "x": 28, + "y": 3, "average_household_income_change": 1213.1704992739687, "relative_household_income_change": -3.5e-9, "population": 60503.947543959046 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -256.0, + "average_household_income_change_upper": -183.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000150", "local_authority_name": "Local Authority 150", - "x": 25, - "y": 4, + "x": 29, + "y": 3, "average_household_income_change": -219.29221357147676, "relative_household_income_change": -3.5e-9, "population": 64616.75513116325 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1626.0, + "average_household_income_change_upper": 2207.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000151", "local_authority_name": "Local Authority 151", - "x": 26, - "y": 4, + "x": 30, + "y": 3, "average_household_income_change": 1916.5046242970254, "relative_household_income_change": 1e-12, "population": 79823.49679126855 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 613.0, + "average_household_income_change_upper": 838.0, + "relative_household_income_change_lower": 0.717, + "relative_household_income_change_upper": 0.9711, "local_authority_code": "E06000000152", "local_authority_name": "Local Authority 152", - "x": 27, - "y": 4, + "x": 31, + "y": 3, "average_household_income_change": 725.2624870496547, "relative_household_income_change": 0.844017537795965, "population": 62800.32153726286 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1100.0, + "average_household_income_change_upper": 1497.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000153", "local_authority_name": "Local Authority 153", - "x": 28, - "y": 4, + "x": 32, + "y": 3, "average_household_income_change": 1298.767112545541, "relative_household_income_change": -3.5e-9, "population": 66498.84726634368 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2233.0, + "average_household_income_change_upper": -1644.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000154", "local_authority_name": "Local Authority 154", - "x": 29, - "y": 4, + "x": 33, + "y": 3, "average_household_income_change": -1938.804885839403, "relative_household_income_change": -0.0, "population": 61460.28946534437 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 962.0, + "average_household_income_change_upper": 1310.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000155", "local_authority_name": "Local Authority 155", - "x": null, - "y": null, + "x": 34, + "y": 3, "average_household_income_change": 1136.131498777778, "relative_household_income_change": -0.0, "population": 67484.6362362209 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -26.0, + "average_household_income_change_upper": -13.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000156", "local_authority_name": "Local Authority 156", - "x": 0, - "y": 5, + "x": 35, + "y": 3, "average_household_income_change": -19.944300086543354, "relative_household_income_change": -3.5e-9, "population": 57647.27599030901 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1619.0, + "average_household_income_change_upper": -1190.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000157", "local_authority_name": "Local Authority 157", - "x": 1, - "y": 5, + "x": 36, + "y": 3, "average_household_income_change": -1404.5693292455107, "relative_household_income_change": -0.0, "population": 140489.2540612634 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1344.0, + "average_household_income_change_upper": 1826.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000158", "local_authority_name": "Local Authority 158", - "x": 2, - "y": 5, + "x": 37, + "y": 3, "average_household_income_change": 1585.0801748542729, "relative_household_income_change": -3.5e-9, "population": 96200.97948146173 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 990.0, + "average_household_income_change_upper": 1348.0, + "relative_household_income_change_lower": 0.6956, + "relative_household_income_change_upper": 0.9421, "local_authority_code": "E06000000159", "local_authority_name": "Local Authority 159", - "x": 3, - "y": 5, + "x": 38, + "y": 3, "average_household_income_change": 1168.9720491958878, "relative_household_income_change": 0.8188434437327052, "population": 54464.32197461528 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1284.0, + "average_household_income_change_upper": 1746.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000160", "local_authority_name": "Local Authority 160", - "x": 4, - "y": 5, + "x": 39, + "y": 3, "average_household_income_change": 1514.9856804708609, "relative_household_income_change": -3.5e-9, "population": 50183.4497151524 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1534.0, + "average_household_income_change_upper": -1128.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000161", "local_authority_name": "Local Authority 161", - "x": 5, - "y": 5, + "x": 0, + "y": 4, "average_household_income_change": -1331.1166293341512, "relative_household_income_change": -3.5e-9, "population": 58108.164802832755 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -551.0, + "average_household_income_change_upper": -401.0, + "relative_household_income_change_lower": 0.7419, + "relative_household_income_change_upper": 1.0048, "local_authority_code": "E06000000162", "local_authority_name": "Local Authority 162", - "x": null, - "y": null, + "x": 1, + "y": 4, "average_household_income_change": -476.03320757418555, "relative_household_income_change": 0.8733176798470372, "population": 102673.4921284775 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 14.0, + "average_household_income_change_upper": 27.0, + "relative_household_income_change_lower": 0.3404, + "relative_household_income_change_upper": 0.4616, "local_authority_code": "E06000000163", "local_authority_name": "Local Authority 163", - "x": 7, - "y": 5, + "x": 2, + "y": 4, "average_household_income_change": 20.200059195911724, "relative_household_income_change": 0.4010141399599696, "population": 148128.53103472033 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1648.0, + "average_household_income_change_upper": 2238.0, + "relative_household_income_change_lower": 0.2493, + "relative_household_income_change_upper": 0.3384, "local_authority_code": "E06000000164", "local_authority_name": "Local Authority 164", - "x": 8, - "y": 5, + "x": 3, + "y": 4, "average_household_income_change": 1943.317850704771, "relative_household_income_change": 0.2938326528671249, "population": 53467.82852088674 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1279.0, + "average_household_income_change_upper": -939.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000165", "local_authority_name": "Local Authority 165", - "x": 9, - "y": 5, + "x": 4, + "y": 4, "average_household_income_change": -1108.9593653220206, "relative_household_income_change": -3.5e-9, "population": 71870.87879550472 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1202.0, + "average_household_income_change_upper": 1634.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000166", "local_authority_name": "Local Authority 166", - "x": 10, - "y": 5, + "x": 5, + "y": 4, "average_household_income_change": 1417.6707836259984, "relative_household_income_change": -3.5e-9, "population": 85702.49386785214 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1374.0, + "average_household_income_change_upper": -1009.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000167", "local_authority_name": "Local Authority 167", - "x": 11, - "y": 5, + "x": 6, + "y": 4, "average_household_income_change": -1191.6164426791443, "relative_household_income_change": -0.0, "population": 92120.03448182667 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1753.0, + "average_household_income_change_upper": -1290.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000168", "local_authority_name": "Local Authority 168", - "x": 12, - "y": 5, + "x": 7, + "y": 4, "average_household_income_change": -1521.2609606051815, "relative_household_income_change": 1e-12, "population": 96889.65526830831 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1241.0, + "average_household_income_change_upper": -911.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000169", "local_authority_name": "Local Authority 169", - "x": null, - "y": null, + "x": 8, + "y": 4, "average_household_income_change": -1075.9555412420489, "relative_household_income_change": -3.5e-9, "population": 64098.17611631555 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1772.0, + "average_household_income_change_upper": -1303.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000170", "local_authority_name": "Local Authority 170", - "x": 14, - "y": 5, + "x": 9, + "y": 4, "average_household_income_change": -1537.4856380387891, "relative_household_income_change": 0.0, "population": 51758.74338339896 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1162.0, + "average_household_income_change_upper": 1580.0, + "relative_household_income_change_lower": 0.4976, + "relative_household_income_change_upper": 0.6743, "local_authority_code": "E06000000171", "local_authority_name": "Local Authority 171", - "x": 15, - "y": 5, + "x": 10, + "y": 4, "average_household_income_change": 1371.007153530756, "relative_household_income_change": 0.5859568395067843, "population": 70540.06204219378 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2196.0, + "average_household_income_change_upper": -1617.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000172", "local_authority_name": "Local Authority 172", - "x": 16, - "y": 5, + "x": 11, + "y": 4, "average_household_income_change": -1906.5786914495363, "relative_household_income_change": -3.5e-9, "population": 101829.17278456208 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 599.0, + "average_household_income_change_upper": 819.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000173", "local_authority_name": "Local Authority 173", - "x": 17, - "y": 5, + "x": 12, + "y": 4, "average_household_income_change": 708.8092408487393, "relative_household_income_change": -3.5e-9, "population": 139656.85560940191 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -317.0, + "average_household_income_change_upper": -228.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000174", "local_authority_name": "Local Authority 174", - "x": 18, - "y": 5, + "x": 13, + "y": 4, "average_household_income_change": -272.20699932632454, "relative_household_income_change": -3.5e-9, "population": 54984.043048109976 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2082.0, + "average_household_income_change_upper": -1533.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000175", "local_authority_name": "Local Authority 175", - "x": 19, - "y": 5, + "x": 14, + "y": 4, "average_household_income_change": -1807.2069435793342, "relative_household_income_change": -3.5e-9, "population": 66932.47835188187 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 783.0, + "average_household_income_change_upper": 1068.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000176", "local_authority_name": "Local Authority 176", - "x": null, - "y": null, + "x": 15, + "y": 4, "average_household_income_change": 925.4429578341852, "relative_household_income_change": 1e-12, "population": 116852.65238026805 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -650.0, + "average_household_income_change_upper": -474.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000177", "local_authority_name": "Local Authority 177", - "x": 21, - "y": 5, + "x": 16, + "y": 4, "average_household_income_change": -561.9776294311647, "relative_household_income_change": -3.5e-9, "population": 101182.57511547924 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -127.0, + "average_household_income_change_upper": -88.0, + "relative_household_income_change_lower": 0.4969, + "relative_household_income_change_upper": 0.6733, "local_authority_code": "E06000000178", "local_authority_name": "Local Authority 178", - "x": 22, - "y": 5, + "x": 17, + "y": 4, "average_household_income_change": -107.53985599744986, "relative_household_income_change": 0.5851294909850242, "population": 126404.4489031002 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2121.0, + "average_household_income_change_upper": -1562.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000179", "local_authority_name": "Local Authority 179", - "x": 23, - "y": 5, + "x": 18, + "y": 4, "average_household_income_change": -1841.4547555666206, "relative_household_income_change": -0.0, "population": 102623.3963837607 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -49.0, + "average_household_income_change_upper": -30.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000180", "local_authority_name": "Local Authority 180", - "x": 24, - "y": 5, + "x": 19, + "y": 4, "average_household_income_change": -39.51880574533817, "relative_household_income_change": 1e-12, "population": 92619.37182461639 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -671.0, + "average_household_income_change_upper": -490.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000181", "local_authority_name": "Local Authority 181", - "x": 25, - "y": 5, + "x": 20, + "y": 4, "average_household_income_change": -580.1224130447231, "relative_household_income_change": 1e-12, "population": 96884.8756462903 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 840.0, + "average_household_income_change_upper": 1144.0, + "relative_household_income_change_lower": 0.5479, + "relative_household_income_change_upper": 0.7423, "local_authority_code": "E06000000182", "local_authority_name": "Local Authority 182", - "x": 26, - "y": 5, + "x": 21, + "y": 4, "average_household_income_change": 992.1120432616349, "relative_household_income_change": 0.6451118196633416, "population": 73957.67565706733 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1995.0, + "average_household_income_change_upper": -1469.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000183", "local_authority_name": "Local Authority 183", - "x": null, - "y": null, + "x": 22, + "y": 4, "average_household_income_change": -1732.1059858114807, "relative_household_income_change": 1e-12, "population": 65395.68845025848 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 600.0, + "average_household_income_change_upper": 821.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000184", "local_authority_name": "Local Authority 184", - "x": 28, - "y": 5, + "x": 23, + "y": 4, "average_household_income_change": 710.5325151252426, "relative_household_income_change": -3.5e-9, "population": 109535.8179164822 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1554.0, + "average_household_income_change_upper": 2111.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000185", "local_authority_name": "Local Authority 185", - "x": 29, - "y": 5, + "x": 24, + "y": 4, "average_household_income_change": 1832.2854393017299, "relative_household_income_change": -3.5e-9, "population": 60669.82708708667 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1543.0, + "average_household_income_change_upper": 2096.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000186", "local_authority_name": "Local Authority 186", - "x": 30, - "y": 5, + "x": 25, + "y": 4, "average_household_income_change": 1819.569360396747, "relative_household_income_change": 0.0, "population": 79655.1474051923 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 344.0, + "average_household_income_change_upper": 473.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000187", "local_authority_name": "Local Authority 187", - "x": 0, - "y": 6, + "x": 26, + "y": 4, "average_household_income_change": 408.54837765137654, "relative_household_income_change": -0.0, "population": 130590.61729258868 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1894.0, + "average_household_income_change_upper": -1394.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000188", "local_authority_name": "Local Authority 188", - "x": 1, - "y": 6, + "x": 27, + "y": 4, "average_household_income_change": -1643.6986591969683, "relative_household_income_change": 1e-12, "population": 59909.55130953969 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2109.0, + "average_household_income_change_upper": -1553.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000189", "local_authority_name": "Local Authority 189", - "x": 2, - "y": 6, + "x": 28, + "y": 4, "average_household_income_change": -1830.8478998657392, "relative_household_income_change": 1e-12, "population": 59489.20805628684 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2195.0, + "average_household_income_change_upper": -1616.0, + "relative_household_income_change_lower": 0.722, + "relative_household_income_change_upper": 0.9779, "local_authority_code": "E06000000190", "local_authority_name": "Local Authority 190", - "x": null, - "y": null, + "x": 29, + "y": 4, "average_household_income_change": -1905.7436084963838, "relative_household_income_change": 0.8499679168235974, "population": 52174.8103666158 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 640.0, + "average_household_income_change_upper": 874.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000191", "local_authority_name": "Local Authority 191", - "x": 4, - "y": 6, + "x": 30, + "y": 4, "average_household_income_change": 757.2216364786452, "relative_household_income_change": -3.5e-9, "population": 129939.34349845978 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1501.0, + "average_household_income_change_upper": 2039.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000192", "local_authority_name": "Local Authority 192", - "x": 5, - "y": 6, + "x": 31, + "y": 4, "average_household_income_change": 1770.2853720417813, "relative_household_income_change": -0.0, "population": 87426.04381781204 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 293.0, + "average_household_income_change_upper": 405.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000193", "local_authority_name": "Local Authority 193", - "x": 6, - "y": 6, + "x": 32, + "y": 4, "average_household_income_change": 348.9531695535768, "relative_household_income_change": 0.0, "population": 89746.21562269032 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -861.0, + "average_household_income_change_upper": -630.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000194", "local_authority_name": "Local Authority 194", - "x": 7, - "y": 6, + "x": 33, + "y": 4, "average_household_income_change": -745.3033309829049, "relative_household_income_change": 0.0, "population": 138162.6468560306 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 883.0, + "average_household_income_change_upper": 1203.0, + "relative_household_income_change_lower": 0.0577, + "relative_household_income_change_upper": 0.0792, "local_authority_code": "E06000000195", "local_authority_name": "Local Authority 195", - "x": 8, - "y": 6, + "x": 34, + "y": 4, "average_household_income_change": 1042.848222721133, "relative_household_income_change": 0.06846816595354055, "population": 68468.23825403182 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2161.0, + "average_household_income_change_upper": -1591.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000196", "local_authority_name": "Local Authority 196", - "x": 9, - "y": 6, + "x": 35, + "y": 4, "average_household_income_change": -1875.6841411500104, "relative_household_income_change": -3.5e-9, "population": 98053.93559753371 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1425.0, + "average_household_income_change_upper": 1936.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000197", "local_authority_name": "Local Authority 197", - "x": null, - "y": null, + "x": 36, + "y": 4, "average_household_income_change": 1680.4842438289293, "relative_household_income_change": 1e-12, "population": 110871.29037186065 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 299.0, + "average_household_income_change_upper": 413.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000198", "local_authority_name": "Local Authority 198", - "x": 11, - "y": 6, + "x": 37, + "y": 4, "average_household_income_change": 355.7356241268361, "relative_household_income_change": -3.5e-9, "population": 61445.557257757966 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1909.0, + "average_household_income_change_upper": -1405.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000199", "local_authority_name": "Local Authority 199", - "x": 12, - "y": 6, + "x": 38, + "y": 4, "average_household_income_change": -1656.5910190820948, "relative_household_income_change": 1e-12, "population": 75125.88651361875 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1288.0, + "average_household_income_change_upper": 1751.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000200", "local_authority_name": "Local Authority 200", - "x": 13, - "y": 6, + "x": 39, + "y": 4, "average_household_income_change": 1519.5281972087762, "relative_household_income_change": -3.5e-9, "population": 132081.12620030972 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1030.0, + "average_household_income_change_upper": -755.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000201", "local_authority_name": "Local Authority 201", - "x": 14, - "y": 6, + "x": 0, + "y": 5, "average_household_income_change": -892.9346315357928, "relative_household_income_change": 0.0, "population": 112829.92103707488 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1595.0, + "average_household_income_change_upper": 2166.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000202", "local_authority_name": "Local Authority 202", - "x": 15, - "y": 6, + "x": 1, + "y": 5, "average_household_income_change": 1880.715568929332, "relative_household_income_change": -3.5e-9, "population": 110874.8283873219 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 713.0, + "average_household_income_change_upper": 972.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000203", "local_authority_name": "Local Authority 203", - "x": 16, - "y": 6, + "x": 2, + "y": 5, "average_household_income_change": 842.4145738218903, "relative_household_income_change": -3.5e-9, "population": 60412.61694731236 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -815.0, + "average_household_income_change_upper": -596.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000204", "local_authority_name": "Local Authority 204", - "x": null, - "y": null, + "x": 3, + "y": 5, "average_household_income_change": -705.471357896287, "relative_household_income_change": -3.5e-9, "population": 138517.7948301562 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1524.0, + "average_household_income_change_upper": 2071.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000205", "local_authority_name": "Local Authority 205", - "x": 18, - "y": 6, + "x": 4, + "y": 5, "average_household_income_change": 1797.4377063971422, "relative_household_income_change": 0.0, "population": 55956.24889439437 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 731.0, + "average_household_income_change_upper": 998.0, + "relative_household_income_change_lower": 0.0283, + "relative_household_income_change_upper": 0.0394, "local_authority_code": "E06000000206", "local_authority_name": "Local Authority 206", - "x": 19, - "y": 6, + "x": 5, + "y": 5, "average_household_income_change": 864.4317426019547, "relative_household_income_change": 0.033828876403854236, "population": 57600.72894162411 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -68.0, + "average_household_income_change_upper": -44.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000207", "local_authority_name": "Local Authority 207", - "x": 20, - "y": 6, + "x": 6, + "y": 5, "average_household_income_change": -55.65987599011679, "relative_household_income_change": 1e-12, "population": 108950.25500093267 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1787.0, + "average_household_income_change_upper": -1315.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000208", "local_authority_name": "Local Authority 208", - "x": 21, - "y": 6, + "x": 7, + "y": 5, "average_household_income_change": -1550.8351036272572, "relative_household_income_change": -3.5e-9, "population": 124542.21626147522 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -981.0, + "average_household_income_change_upper": -719.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000209", "local_authority_name": "Local Authority 209", - "x": 22, - "y": 6, + "x": 8, + "y": 5, "average_household_income_change": -850.3270689942401, "relative_household_income_change": 1e-12, "population": 149228.6199633644 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -96.0, + "average_household_income_change_upper": -65.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000210", "local_authority_name": "Local Authority 210", - "x": 23, - "y": 6, + "x": 9, + "y": 5, "average_household_income_change": -80.61036125681741, "relative_household_income_change": -3.5e-9, "population": 75852.58545389926 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 637.0, + "average_household_income_change_upper": 869.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000211", "local_authority_name": "Local Authority 211", - "x": null, - "y": null, + "x": 10, + "y": 5, "average_household_income_change": 752.9782555919926, "relative_household_income_change": 1e-12, "population": 138696.91612606982 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1132.0, + "average_household_income_change_upper": 1539.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000212", "local_authority_name": "Local Authority 212", - "x": 25, - "y": 6, + "x": 11, + "y": 5, "average_household_income_change": 1335.5289377583035, "relative_household_income_change": 1e-12, "population": 69346.05149303882 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1606.0, + "average_household_income_change_upper": 2181.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000213", "local_authority_name": "Local Authority 213", - "x": 26, - "y": 6, + "x": 12, + "y": 5, "average_household_income_change": 1893.1738017177952, "relative_household_income_change": 0.0, "population": 132010.16436654507 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -659.0, + "average_household_income_change_upper": -481.0, + "relative_household_income_change_lower": 0.6653, + "relative_household_income_change_upper": 0.9012, "local_authority_code": "E06000000214", "local_authority_name": "Local Authority 214", - "x": 27, - "y": 6, + "x": 13, + "y": 5, "average_household_income_change": -569.6717516392021, "relative_household_income_change": 0.7832180104424888, "population": 95128.54328083304 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1876.0, + "average_household_income_change_upper": -1380.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000215", "local_authority_name": "Local Authority 215", - "x": 28, - "y": 6, + "x": 14, + "y": 5, "average_household_income_change": -1627.883853584315, "relative_household_income_change": 0.0, "population": 101153.74778774349 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1525.0, + "average_household_income_change_upper": -1121.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000216", "local_authority_name": "Local Authority 216", - "x": 29, - "y": 6, + "x": 15, + "y": 5, "average_household_income_change": -1322.8076449238276, "relative_household_income_change": -3.5e-9, "population": 56347.31430493599 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1288.0, + "average_household_income_change_upper": 1751.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000217", "local_authority_name": "Local Authority 217", - "x": 30, - "y": 6, + "x": 16, + "y": 5, "average_household_income_change": 1519.4125827216465, "relative_household_income_change": -0.0, "population": 141806.4685395045 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1280.0, + "average_household_income_change_upper": 1740.0, + "relative_household_income_change_lower": 0.3836, + "relative_household_income_change_upper": 0.52, "local_authority_code": "E06000000218", "local_authority_name": "Local Authority 218", - "x": null, - "y": null, + "x": 17, + "y": 5, "average_household_income_change": 1509.8863685346876, "relative_household_income_change": 0.4518054828480579, "population": 111379.4220157825 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 726.0, + "average_household_income_change_upper": 990.0, + "relative_household_income_change_lower": 0.5534, + "relative_household_income_change_upper": 0.7498, "local_authority_code": "E06000000219", "local_authority_name": "Local Authority 219", - "x": 1, - "y": 7, + "x": 18, + "y": 5, "average_household_income_change": 858.0575413750009, "relative_household_income_change": 0.6515707052700975, "population": 101392.46293323096 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1305.0, + "average_household_income_change_upper": 1773.0, + "relative_household_income_change_lower": 0.0704, + "relative_household_income_change_upper": 0.0963, "local_authority_code": "E06000000220", "local_authority_name": "Local Authority 220", - "x": 2, - "y": 7, + "x": 19, + "y": 5, "average_household_income_change": 1539.063867606706, "relative_household_income_change": 0.08332081692084092, "population": 106227.72748735608 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 954.0, + "average_household_income_change_upper": 1298.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000221", "local_authority_name": "Local Authority 221", - "x": 3, - "y": 7, + "x": 20, + "y": 5, "average_household_income_change": 1125.9579822870646, "relative_household_income_change": 1e-12, "population": 79072.8218332542 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1690.0, + "average_household_income_change_upper": 2294.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000222", "local_authority_name": "Local Authority 222", - "x": 4, - "y": 7, + "x": 21, + "y": 5, "average_household_income_change": 1991.8295690955406, "relative_household_income_change": 0.0, "population": 87265.51073046919 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1576.0, + "average_household_income_change_upper": 2140.0, + "relative_household_income_change_lower": 0.7386, + "relative_household_income_change_upper": 1.0004, "local_authority_code": "E06000000223", "local_authority_name": "Local Authority 223", - "x": 5, - "y": 7, + "x": 22, + "y": 5, "average_household_income_change": 1857.7571143100572, "relative_household_income_change": 0.8694923657391472, "population": 72659.01155471863 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 492.0, + "average_household_income_change_upper": 674.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000224", "local_authority_name": "Local Authority 224", - "x": 6, - "y": 7, + "x": 23, + "y": 5, "average_household_income_change": 582.8592307220295, "relative_household_income_change": 0.0, "population": 59591.53172435968 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1849.0, + "average_household_income_change_upper": -1361.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000225", "local_authority_name": "Local Authority 225", - "x": null, - "y": null, + "x": 24, + "y": 5, "average_household_income_change": -1604.9195424328059, "relative_household_income_change": -3.5e-9, "population": 91299.67168021623 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2028.0, + "average_household_income_change_upper": -1493.0, + "relative_household_income_change_lower": 0.6186, + "relative_household_income_change_upper": 0.838, "local_authority_code": "E06000000226", "local_authority_name": "Local Authority 226", - "x": 8, - "y": 7, + "x": 25, + "y": 5, "average_household_income_change": -1760.2878795178665, "relative_household_income_change": 0.728341205120138, "population": 59437.467109970996 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2277.0, + "average_household_income_change_upper": -1677.0, + "relative_household_income_change_lower": 0.7314, + "relative_household_income_change_upper": 0.9906, "local_authority_code": "E06000000227", "local_authority_name": "Local Authority 227", - "x": 9, - "y": 7, + "x": 26, + "y": 5, "average_household_income_change": -1976.5708846512755, "relative_household_income_change": 0.8609845659144635, "population": 86764.30335666653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -117.0, + "average_household_income_change_upper": -81.0, + "relative_household_income_change_lower": 0.6164, + "relative_household_income_change_upper": 0.835, "local_authority_code": "E06000000228", "local_authority_name": "Local Authority 228", - "x": 10, - "y": 7, + "x": 27, + "y": 5, "average_household_income_change": -98.97644323572274, "relative_household_income_change": 0.7256565627219298, "population": 86854.77541952155 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1074.0, + "average_household_income_change_upper": -788.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000229", "local_authority_name": "Local Authority 229", - "x": 11, - "y": 7, + "x": 28, + "y": 5, "average_household_income_change": -931.2346405171978, "relative_household_income_change": 1e-12, "population": 94065.9443818011 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -509.0, + "average_household_income_change_upper": -370.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000230", "local_authority_name": "Local Authority 230", - "x": 12, - "y": 7, + "x": 29, + "y": 5, "average_household_income_change": -439.9071701350206, "relative_household_income_change": -3.5e-9, "population": 134163.5908219147 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1834.0, + "average_household_income_change_upper": -1349.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000231", "local_authority_name": "Local Authority 231", - "x": 13, - "y": 7, + "x": 30, + "y": 5, "average_household_income_change": -1591.3535006330312, "relative_household_income_change": -0.0, "population": 64793.23200572934 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 339.0, + "average_household_income_change_upper": 468.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000232", "local_authority_name": "Local Authority 232", - "x": null, - "y": null, + "x": 31, + "y": 5, "average_household_income_change": 403.4903225216731, "relative_household_income_change": 1e-12, "population": 127856.55191534641 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1404.0, + "average_household_income_change_upper": 1908.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000233", "local_authority_name": "Local Authority 233", - "x": 15, - "y": 7, + "x": 32, + "y": 5, "average_household_income_change": 1656.1380154606948, "relative_household_income_change": 1e-12, "population": 111461.87010410217 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1243.0, + "average_household_income_change_upper": 1690.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000234", "local_authority_name": "Local Authority 234", - "x": 16, - "y": 7, + "x": 33, + "y": 5, "average_household_income_change": 1466.347687579943, "relative_household_income_change": 0.0, "population": 60236.23142612746 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2192.0, + "average_household_income_change_upper": -1614.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000235", "local_authority_name": "Local Authority 235", - "x": 17, - "y": 7, + "x": 34, + "y": 5, "average_household_income_change": -1903.0397927241559, "relative_household_income_change": 1e-12, "population": 99830.54271526785 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1819.0, + "average_household_income_change_upper": -1339.0, + "relative_household_income_change_lower": 0.433, + "relative_household_income_change_upper": 0.5869, "local_authority_code": "E06000000236", "local_authority_name": "Local Authority 236", - "x": 18, - "y": 7, + "x": 35, + "y": 5, "average_household_income_change": -1579.0994583602003, "relative_household_income_change": 0.509998146550363, "population": 138565.29795023135 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 599.0, + "average_household_income_change_upper": 819.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000237", "local_authority_name": "Local Authority 237", - "x": 19, - "y": 7, + "x": 36, + "y": 5, "average_household_income_change": 709.2203209255472, "relative_household_income_change": 0.0, "population": 124197.3632538812 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 708.0, + "average_household_income_change_upper": 967.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000238", "local_authority_name": "Local Authority 238", - "x": 20, - "y": 7, + "x": 37, + "y": 5, "average_household_income_change": 837.6158957883022, "relative_household_income_change": -3.5e-9, "population": 143454.69827079526 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -598.0, + "average_household_income_change_upper": -436.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000239", "local_authority_name": "Local Authority 239", - "x": null, - "y": null, + "x": 38, + "y": 5, "average_household_income_change": -516.9005246383272, "relative_household_income_change": -3.5e-9, "population": 57037.494564974375 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 562.0, + "average_household_income_change_upper": 768.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000240", "local_authority_name": "Local Authority 240", - "x": 22, - "y": 7, + "x": 39, + "y": 5, "average_household_income_change": 665.0094012510017, "relative_household_income_change": -3.5e-9, "population": 55242.63945586822 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 542.0, + "average_household_income_change_upper": 742.0, + "relative_household_income_change_lower": 0.5852, + "relative_household_income_change_upper": 0.7928, "local_authority_code": "E06000000241", "local_authority_name": "Local Authority 241", - "x": 23, - "y": 7, + "x": 0, + "y": 6, "average_household_income_change": 641.8757097418747, "relative_household_income_change": 0.6890206788110149, "population": 110740.67170974993 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 709.0, + "average_household_income_change_upper": 968.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000242", "local_authority_name": "Local Authority 242", - "x": 24, - "y": 7, + "x": 1, + "y": 6, "average_household_income_change": 838.311650071085, "relative_household_income_change": 0.0, "population": 82383.11841393165 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 876.0, + "average_household_income_change_upper": 1193.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000243", "local_authority_name": "Local Authority 243", - "x": 25, - "y": 7, + "x": 2, + "y": 6, "average_household_income_change": 1034.249538488258, "relative_household_income_change": -0.0, "population": 52093.415519193295 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1215.0, + "average_household_income_change_upper": -892.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000244", "local_authority_name": "Local Authority 244", - "x": 26, - "y": 7, + "x": 3, + "y": 6, "average_household_income_change": -1053.5590021723342, "relative_household_income_change": -3.5e-9, "population": 128568.646242031 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 348.0, + "average_household_income_change_upper": 479.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000245", "local_authority_name": "Local Authority 245", - "x": 27, - "y": 7, + "x": 4, + "y": 6, "average_household_income_change": 413.1341063514338, "relative_household_income_change": 1e-12, "population": 95028.43308140374 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 614.0, + "average_household_income_change_upper": 839.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000246", "local_authority_name": "Local Authority 246", - "x": null, - "y": null, + "x": 5, + "y": 6, "average_household_income_change": 726.7269295469209, "relative_household_income_change": 0.0, "population": 127274.43476063575 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1535.0, + "average_household_income_change_upper": 2084.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000247", "local_authority_name": "Local Authority 247", - "x": 29, - "y": 7, + "x": 6, + "y": 6, "average_household_income_change": 1809.5052200800587, "relative_household_income_change": 1e-12, "population": 59565.128533094845 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1221.0, + "average_household_income_change_upper": -896.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000248", "local_authority_name": "Local Authority 248", - "x": 30, - "y": 7, + "x": 7, + "y": 6, "average_household_income_change": -1058.6836508131187, "relative_household_income_change": 1e-12, "population": 82279.44191562528 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1228.0, + "average_household_income_change_upper": 1669.0, + "relative_household_income_change_lower": 0.3951, + "relative_household_income_change_upper": 0.5356, "local_authority_code": "E06000000249", "local_authority_name": "Local Authority 249", - "x": 0, - "y": 8, + "x": 8, + "y": 6, "average_household_income_change": 1448.4446061658857, "relative_household_income_change": 0.4653143220721977, "population": 123530.70098753371 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -248.0, + "average_household_income_change_upper": -177.0, + "relative_household_income_change_lower": 0.7245, + "relative_household_income_change_upper": 0.9813, "local_authority_code": "E06000000250", "local_authority_name": "Local Authority 250", - "x": 1, - "y": 8, + "x": 9, + "y": 6, "average_household_income_change": -212.2114626921525, "relative_household_income_change": 0.852927137010199, "population": 79092.73955186279 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -620.0, + "average_household_income_change_upper": -453.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000251", "local_authority_name": "Local Authority 251", - "x": 2, - "y": 8, + "x": 10, + "y": 6, "average_household_income_change": -536.492171462005, "relative_household_income_change": 1e-12, "population": 54781.582558346054 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1076.0, + "average_household_income_change_upper": 1464.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000252", "local_authority_name": "Local Authority 252", - "x": 3, - "y": 8, + "x": 11, + "y": 6, "average_household_income_change": 1270.0802925219489, "relative_household_income_change": 1e-12, "population": 100948.36759624102 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -464.0, + "average_household_income_change_upper": -337.0, + "relative_household_income_change_lower": 0.7746, + "relative_household_income_change_upper": 1.0491, "local_authority_code": "E06000000253", "local_authority_name": "Local Authority 253", - "x": null, - "y": null, + "x": 12, + "y": 6, "average_household_income_change": -400.4186572841455, "relative_household_income_change": 0.9118611203153317, "population": 80534.73349695683 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1218.0, + "average_household_income_change_upper": 1656.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000254", "local_authority_name": "Local Authority 254", - "x": 5, - "y": 8, + "x": 13, + "y": 6, "average_household_income_change": 1436.8345817862908, "relative_household_income_change": 0.0, "population": 92294.26908433915 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 470.0, + "average_household_income_change_upper": 644.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000255", "local_authority_name": "Local Authority 255", - "x": 6, - "y": 8, + "x": 14, + "y": 6, "average_household_income_change": 557.144812679411, "relative_household_income_change": -3.5e-9, "population": 110218.69910899561 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -301.0, + "average_household_income_change_upper": -216.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000256", "local_authority_name": "Local Authority 256", - "x": 7, - "y": 8, + "x": 15, + "y": 6, "average_household_income_change": -258.6649908064569, "relative_household_income_change": 1e-12, "population": 57714.24382857021 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2026.0, + "average_household_income_change_upper": -1491.0, + "relative_household_income_change_lower": 0.6498, + "relative_household_income_change_upper": 0.8802, "local_authority_code": "E06000000257", "local_authority_name": "Local Authority 257", - "x": 8, - "y": 8, + "x": 16, + "y": 6, "average_household_income_change": -1758.3705939542908, "relative_household_income_change": 0.764995400685606, "population": 131089.37081954637 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2054.0, + "average_household_income_change_upper": -1512.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000258", "local_authority_name": "Local Authority 258", - "x": 9, - "y": 8, + "x": 17, + "y": 6, "average_household_income_change": -1783.2292403988529, "relative_household_income_change": 1e-12, "population": 104494.64950338395 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1530.0, + "average_household_income_change_upper": -1125.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000259", "local_authority_name": "Local Authority 259", - "x": 10, - "y": 8, + "x": 18, + "y": 6, "average_household_income_change": -1327.8244041906157, "relative_household_income_change": 1e-12, "population": 113337.99406176846 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 579.0, + "average_household_income_change_upper": 791.0, + "relative_household_income_change_lower": 0.6512, + "relative_household_income_change_upper": 0.8821, "local_authority_code": "E06000000260", "local_authority_name": "Local Authority 260", - "x": null, - "y": null, + "x": 19, + "y": 6, "average_household_income_change": 684.877297170174, "relative_household_income_change": 0.7666200435868592, "population": 53782.60546490354 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2032.0, + "average_household_income_change_upper": -1496.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000261", "local_authority_name": "Local Authority 261", - "x": 12, - "y": 8, + "x": 20, + "y": 6, "average_household_income_change": -1764.2297057997705, "relative_household_income_change": 0.0, "population": 55563.5849283389 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -298.0, + "average_household_income_change_upper": -214.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000262", "local_authority_name": "Local Authority 262", - "x": 13, - "y": 8, + "x": 21, + "y": 6, "average_household_income_change": -255.91025902887486, "relative_household_income_change": 1e-12, "population": 99982.94437622868 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -303.0, + "average_household_income_change_upper": -218.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000263", "local_authority_name": "Local Authority 263", - "x": 14, - "y": 8, + "x": 22, + "y": 6, "average_household_income_change": -260.18748124346524, "relative_household_income_change": 1e-12, "population": 85148.0146588767 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -911.0, + "average_household_income_change_upper": -667.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000264", "local_authority_name": "Local Authority 264", - "x": 15, - "y": 8, + "x": 23, + "y": 6, "average_household_income_change": -789.2344583138699, "relative_household_income_change": 1e-12, "population": 108161.34969368408 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -670.0, + "average_household_income_change_upper": -489.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000265", "local_authority_name": "Local Authority 265", - "x": 16, - "y": 8, + "x": 24, + "y": 6, "average_household_income_change": -579.3256462120007, "relative_household_income_change": -0.0, "population": 91725.54291633592 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -1683.0, + "average_household_income_change_upper": -1238.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000266", "local_authority_name": "Local Authority 266", - "x": 17, - "y": 8, + "x": 25, + "y": 6, "average_household_income_change": -1460.1637924293796, "relative_household_income_change": 1e-12, "population": 70001.25677576543 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -347.0, + "average_household_income_change_upper": -250.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000267", "local_authority_name": "Local Authority 267", - "x": null, - "y": null, + "x": 26, + "y": 6, "average_household_income_change": -298.8151888887605, "relative_household_income_change": -3.5e-9, "population": 123887.64729678868 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1720.0, + "average_household_income_change_upper": -1265.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000268", "local_authority_name": "Local Authority 268", - "x": 19, - "y": 8, + "x": 27, + "y": 6, "average_household_income_change": -1492.7346834787274, "relative_household_income_change": 0.0, "population": 90000.38917674075 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 226.0, + "average_household_income_change_upper": 314.0, + "relative_household_income_change_lower": 0.6526, + "relative_household_income_change_upper": 0.884, "local_authority_code": "E06000000269", "local_authority_name": "Local Authority 269", - "x": 20, - "y": 8, + "x": 28, + "y": 6, "average_household_income_change": 269.95612119315916, "relative_household_income_change": 0.7682928997059275, "population": 87441.05104136605 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2045.0, + "average_household_income_change_upper": -1506.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000270", "local_authority_name": "Local Authority 270", - "x": 21, - "y": 8, + "x": 29, + "y": 6, "average_household_income_change": -1775.579629167957, "relative_household_income_change": -3.5e-9, "population": 93847.56933635911 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1626.0, + "average_household_income_change_upper": 2208.0, + "relative_household_income_change_lower": 0.6787, + "relative_household_income_change_upper": 0.9193, "local_authority_code": "E06000000271", "local_authority_name": "Local Authority 271", - "x": 22, - "y": 8, + "x": 30, + "y": 6, "average_household_income_change": 1917.3099341009201, "relative_household_income_change": 0.7989601983692834, "population": 88816.01516746833 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1208.0, + "average_household_income_change_upper": 1643.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000272", "local_authority_name": "Local Authority 272", - "x": 23, - "y": 8, + "x": 31, + "y": 6, "average_household_income_change": 1425.287215554047, "relative_household_income_change": 1e-12, "population": 131712.46458855222 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 178.0, + "average_household_income_change_upper": 250.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000273", "local_authority_name": "Local Authority 273", - "x": 24, - "y": 8, + "x": 32, + "y": 6, "average_household_income_change": 214.07954117904282, "relative_household_income_change": -0.0, "population": 85239.5152204162 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 400.0, + "average_household_income_change_upper": 549.0, + "relative_household_income_change_lower": 0.3408, + "relative_household_income_change_upper": 0.4621, "local_authority_code": "E06000000274", "local_authority_name": "Local Authority 274", - "x": null, - "y": null, + "x": 33, + "y": 6, "average_household_income_change": 474.26196866968786, "relative_household_income_change": 0.40147264541517713, "population": 149088.15989006817 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 395.0, + "average_household_income_change_upper": 543.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000275", "local_authority_name": "Local Authority 275", - "x": 26, - "y": 8, + "x": 34, + "y": 6, "average_household_income_change": 469.02445254080095, "relative_household_income_change": 0.0, "population": 51716.52945984774 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1117.0, + "average_household_income_change_upper": 1520.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000276", "local_authority_name": "Local Authority 276", - "x": 27, - "y": 8, + "x": 35, + "y": 6, "average_household_income_change": 1318.3074227966679, "relative_household_income_change": -0.0, "population": 143507.4378546935 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -565.0, + "average_household_income_change_upper": -412.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000277", "local_authority_name": "Local Authority 277", - "x": 28, - "y": 8, + "x": 36, + "y": 6, "average_household_income_change": -488.26844121446686, "relative_household_income_change": 0.0, "population": 109268.00346399064 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 914.0, + "average_household_income_change_upper": 1245.0, + "relative_household_income_change_lower": 0.3428, + "relative_household_income_change_upper": 0.4648, "local_authority_code": "E06000000278", "local_authority_name": "Local Authority 278", - "x": 29, - "y": 8, + "x": 37, + "y": 6, "average_household_income_change": 1079.9362248733341, "relative_household_income_change": 0.4038236963373144, "population": 79289.57169939693 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 331.0, + "average_household_income_change_upper": 456.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000279", "local_authority_name": "Local Authority 279", - "x": 30, - "y": 8, + "x": 38, + "y": 6, "average_household_income_change": 393.5887992701219, "relative_household_income_change": -0.0, "population": 82588.28138747317 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1328.0, + "average_household_income_change_upper": -975.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000280", "local_authority_name": "Local Authority 280", - "x": 0, - "y": 9, + "x": 39, + "y": 6, "average_household_income_change": -1151.3682127169345, "relative_household_income_change": -0.0, "population": 92957.50128701699 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2117.0, + "average_household_income_change_upper": -1558.0, + "relative_household_income_change_lower": 0.283, + "relative_household_income_change_upper": 0.3839, "local_authority_code": "E06000000281", "local_authority_name": "Local Authority 281", - "x": null, - "y": null, + "x": 0, + "y": 7, "average_household_income_change": -1837.624942762603, "relative_household_income_change": 0.33343966871360353, "population": 145490.62320200523 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1094.0, + "average_household_income_change_upper": -802.0, + "relative_household_income_change_lower": 0.5831, + "relative_household_income_change_upper": 0.7899, "local_authority_code": "E06000000282", "local_authority_name": "Local Authority 282", - "x": 2, - "y": 9, + "x": 1, + "y": 7, "average_household_income_change": -948.174560909842, "relative_household_income_change": 0.6865062575083919, "population": 126283.14237619072 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 634.0, + "average_household_income_change_upper": 866.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000283", "local_authority_name": "Local Authority 283", - "x": 3, - "y": 9, + "x": 2, + "y": 7, "average_household_income_change": 749.9567568596154, "relative_household_income_change": -3.5e-9, "population": 133516.3436537348 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -2238.0, + "average_household_income_change_upper": -1648.0, + "relative_household_income_change_lower": 0.746, + "relative_household_income_change_upper": 1.0104, "local_authority_code": "E06000000284", "local_authority_name": "Local Authority 284", - "x": 4, - "y": 9, + "x": 3, + "y": 7, "average_household_income_change": -1942.695785205236, "relative_household_income_change": 0.8781793279501448, "population": 81219.27203410128 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2254.0, + "average_household_income_change_upper": -1660.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000285", "local_authority_name": "Local Authority 285", - "x": 5, - "y": 9, + "x": 4, + "y": 7, "average_household_income_change": -1956.9467679013912, "relative_household_income_change": -3.5e-9, "population": 55900.53972903704 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 527.0, + "average_household_income_change_upper": 721.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000286", "local_authority_name": "Local Authority 286", - "x": 6, - "y": 9, + "x": 5, + "y": 7, "average_household_income_change": 623.5927200680891, "relative_household_income_change": -3.5e-9, "population": 79076.26527150461 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 845.0, + "average_household_income_change_upper": 1152.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000287", "local_authority_name": "Local Authority 287", - "x": 7, - "y": 9, + "x": 6, + "y": 7, "average_household_income_change": 998.5169020724971, "relative_household_income_change": 0.0, "population": 121342.67637408897 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2097.0, + "average_household_income_change_upper": -1544.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000288", "local_authority_name": "Local Authority 288", - "x": null, - "y": null, + "x": 7, + "y": 7, "average_household_income_change": -1820.3852673716563, "relative_household_income_change": 1e-12, "population": 132830.3117877651 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1653.0, + "average_household_income_change_upper": 2244.0, + "relative_household_income_change_lower": 0.1017, + "relative_household_income_change_upper": 0.1386, "local_authority_code": "E06000000289", "local_authority_name": "Local Authority 289", - "x": 9, - "y": 9, + "x": 8, + "y": 7, "average_household_income_change": 1948.3789159219884, "relative_household_income_change": 0.12014587257431875, "population": 68445.65221127166 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 356.0, + "average_household_income_change_upper": 490.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000290", "local_authority_name": "Local Authority 290", - "x": 10, - "y": 9, + "x": 9, + "y": 7, "average_household_income_change": 423.4069254456765, "relative_household_income_change": -3.5e-9, "population": 66720.62706762293 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 235.0, + "average_household_income_change_upper": 327.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000291", "local_authority_name": "Local Authority 291", - "x": 11, - "y": 9, + "x": 10, + "y": 7, "average_household_income_change": 281.0095652309492, "relative_household_income_change": 0.0, "population": 70639.58860036393 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 184.0, + "average_household_income_change_upper": 257.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000292", "local_authority_name": "Local Authority 292", - "x": 12, - "y": 9, + "x": 11, + "y": 7, "average_household_income_change": 220.18925132835898, "relative_household_income_change": -3.5e-9, "population": 133300.24112989724 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1173.0, + "average_household_income_change_upper": 1595.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000293", "local_authority_name": "Local Authority 293", - "x": 13, - "y": 9, + "x": 12, + "y": 7, "average_household_income_change": 1384.243091982024, "relative_household_income_change": 1e-12, "population": 79745.25417884401 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 230.0, + "average_household_income_change_upper": 319.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000294", "local_authority_name": "Local Authority 294", - "x": 14, - "y": 9, + "x": 13, + "y": 7, "average_household_income_change": 274.196564320758, "relative_household_income_change": 1e-12, "population": 115781.39119694172 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -425.0, + "average_household_income_change_upper": -308.0, + "relative_household_income_change_lower": 0.2311, + "relative_household_income_change_upper": 0.3137, "local_authority_code": "E06000000295", "local_authority_name": "Local Authority 295", - "x": null, - "y": null, + "x": 14, + "y": 7, "average_household_income_change": -366.2436633812365, "relative_household_income_change": 0.2724055786550509, "population": 118167.67210480243 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -482.0, + "average_household_income_change_upper": -350.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000296", "local_authority_name": "Local Authority 296", - "x": 16, - "y": 9, + "x": 15, + "y": 7, "average_household_income_change": -416.3777338834693, "relative_household_income_change": 0.0, "population": 87322.12558288818 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1138.0, + "average_household_income_change_upper": -835.0, + "relative_household_income_change_lower": 0.8476, + "relative_household_income_change_upper": 1.1478, "local_authority_code": "E06000000297", "local_authority_name": "Local Authority 297", - "x": 17, - "y": 9, + "x": 16, + "y": 7, "average_household_income_change": -986.5104904687718, "relative_household_income_change": 0.9976970816212194, "population": 126700.16226183344 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1085.0, + "average_household_income_change_upper": 1476.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000298", "local_authority_name": "Local Authority 298", - "x": 18, - "y": 9, + "x": 17, + "y": 7, "average_household_income_change": 1280.0499716237775, "relative_household_income_change": 1e-12, "population": 130009.21342011327 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 587.0, + "average_household_income_change_upper": 803.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000299", "local_authority_name": "Local Authority 299", - "x": 19, - "y": 9, + "x": 18, + "y": 7, "average_household_income_change": 695.194567020863, "relative_household_income_change": -3.5e-9, "population": 121749.1602107389 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 528.0, + "average_household_income_change_upper": 723.0, + "relative_household_income_change_lower": 0.84, + "relative_household_income_change_upper": 1.1375, "local_authority_code": "E06000000300", "local_authority_name": "Local Authority 300", - "x": 20, - "y": 9, + "x": 19, + "y": 7, "average_household_income_change": 625.7473745948782, "relative_household_income_change": 0.9887659421326285, "population": 79438.29979595897 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1448.0, + "average_household_income_change_upper": 1967.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000301", "local_authority_name": "Local Authority 301", - "x": 21, - "y": 9, + "x": 20, + "y": 7, "average_household_income_change": 1707.1531326392005, "relative_household_income_change": -3.5e-9, "population": 90319.61710395063 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 303.0, + "average_household_income_change_upper": 418.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000302", "local_authority_name": "Local Authority 302", - "x": null, - "y": null, + "x": 21, + "y": 7, "average_household_income_change": 360.74309708507803, "relative_household_income_change": 1e-12, "population": 78762.98544550533 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1314.0, + "average_household_income_change_upper": -965.0, + "relative_household_income_change_lower": 0.0406, + "relative_household_income_change_upper": 0.0561, "local_authority_code": "E06000000303", "local_authority_name": "Local Authority 303", - "x": 23, - "y": 9, + "x": 22, + "y": 7, "average_household_income_change": -1139.89488155203, "relative_household_income_change": 0.048349037990379484, "population": 74423.26349738428 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1352.0, + "average_household_income_change_upper": 1838.0, + "relative_household_income_change_lower": 0.1916, + "relative_household_income_change_upper": 0.2603, "local_authority_code": "E06000000304", "local_authority_name": "Local Authority 304", - "x": 24, - "y": 9, + "x": 23, + "y": 7, "average_household_income_change": 1594.8169579207745, "relative_household_income_change": 0.22591907290792212, "population": 84032.00044889207 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1246.0, + "average_household_income_change_upper": -915.0, + "relative_household_income_change_lower": 0.7979, + "relative_household_income_change_upper": 1.0805, "local_authority_code": "E06000000305", "local_authority_name": "Local Authority 305", - "x": 25, - "y": 9, + "x": 24, + "y": 7, "average_household_income_change": -1080.1367324794787, "relative_household_income_change": 0.9392024418031214, "population": 121651.5707570977 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -385.0, + "average_household_income_change_upper": -278.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000306", "local_authority_name": "Local Authority 306", - "x": 26, - "y": 9, + "x": 25, + "y": 7, "average_household_income_change": -331.3802060767421, "relative_household_income_change": -3.5e-9, "population": 95417.70209215413 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1888.0, + "average_household_income_change_upper": -1389.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000307", "local_authority_name": "Local Authority 307", - "x": 27, - "y": 9, + "x": 26, + "y": 7, "average_household_income_change": -1638.7362888513878, "relative_household_income_change": 0.0, "population": 117308.15275859929 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1807.0, + "average_household_income_change_upper": -1330.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000308", "local_authority_name": "Local Authority 308", - "x": 28, - "y": 9, + "x": 27, + "y": 7, "average_household_income_change": -1568.4322383589476, "relative_household_income_change": 1e-12, "population": 125862.9003026094 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 673.0, + "average_household_income_change_upper": 919.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000309", "local_authority_name": "Local Authority 309", - "x": null, - "y": null, + "x": 28, + "y": 7, "average_household_income_change": 795.9668868388188, "relative_household_income_change": -3.5e-9, "population": 135858.32239161074 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1640.0, + "average_household_income_change_upper": 2227.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000310", "local_authority_name": "Local Authority 310", - "x": 30, - "y": 9, + "x": 29, + "y": 7, "average_household_income_change": 1933.1381935610436, "relative_household_income_change": 0.0, "population": 124611.61163057359 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -167.0, + "average_household_income_change_upper": -117.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000311", "local_authority_name": "Local Authority 311", - "x": 0, - "y": 10, + "x": 30, + "y": 7, "average_household_income_change": -141.98181117782437, "relative_household_income_change": -0.0, "population": 70609.89099602692 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -68.0, + "average_household_income_change_upper": -44.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000312", "local_authority_name": "Local Authority 312", - "x": 1, - "y": 10, + "x": 31, + "y": 7, "average_household_income_change": -56.44267916476542, "relative_household_income_change": 0.0, "population": 111699.84662409528 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1508.0, + "average_household_income_change_upper": 2048.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000313", "local_authority_name": "Local Authority 313", - "x": 2, - "y": 10, + "x": 32, + "y": 7, "average_household_income_change": 1778.0555217769213, "relative_household_income_change": -3.5e-9, "population": 145693.45404754783 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1474.0, + "average_household_income_change_upper": -1083.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000314", "local_authority_name": "Local Authority 314", - "x": 3, - "y": 10, + "x": 33, + "y": 7, "average_household_income_change": -1278.5715098616452, "relative_household_income_change": -3.5e-9, "population": 76006.43282400742 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1222.0, + "average_household_income_change_upper": -897.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000315", "local_authority_name": "Local Authority 315", - "x": 4, - "y": 10, + "x": 34, + "y": 7, "average_household_income_change": -1059.1789998677025, "relative_household_income_change": -3.5e-9, "population": 65529.9637408226 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1209.0, + "average_household_income_change_upper": -888.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000316", "local_authority_name": "Local Authority 316", - "x": null, - "y": null, + "x": 35, + "y": 7, "average_household_income_change": -1048.4707691627543, "relative_household_income_change": 0.0, "population": 145955.27596523814 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -609.0, + "average_household_income_change_upper": -444.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000317", "local_authority_name": "Local Authority 317", - "x": 6, - "y": 10, + "x": 36, + "y": 7, "average_household_income_change": -526.7537120829884, "relative_household_income_change": -0.0, "population": 145820.206722863 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -2250.0, + "average_household_income_change_upper": -1657.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000318", "local_authority_name": "Local Authority 318", - "x": 7, - "y": 10, + "x": 37, + "y": 7, "average_household_income_change": -1953.7169568476536, "relative_household_income_change": 0.0, "population": 145527.70903551613 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1616.0, + "average_household_income_change_upper": 2195.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000319", "local_authority_name": "Local Authority 319", - "x": 8, - "y": 10, + "x": 38, + "y": 7, "average_household_income_change": 1905.364779664637, "relative_household_income_change": -0.0, "population": 54054.22553493581 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 247.0, + "average_household_income_change_upper": 342.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000320", "local_authority_name": "Local Authority 320", - "x": 9, - "y": 10, + "x": 39, + "y": 7, "average_household_income_change": 294.1624951426873, "relative_household_income_change": -3.5e-9, "population": 53783.2750109446 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -634.0, + "average_household_income_change_upper": -462.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000321", "local_authority_name": "Local Authority 321", - "x": 10, - "y": 10, + "x": 0, + "y": 8, "average_household_income_change": -548.0681646499228, "relative_household_income_change": 1e-12, "population": 124067.3608380831 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1062.0, + "average_household_income_change_upper": -779.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000322", "local_authority_name": "Local Authority 322", - "x": 11, - "y": 10, + "x": 1, + "y": 8, "average_household_income_change": -920.382750538947, "relative_household_income_change": 1e-12, "population": 64399.62052101023 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1144.0, + "average_household_income_change_upper": 1556.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000323", "local_authority_name": "Local Authority 323", - "x": null, - "y": null, + "x": 2, + "y": 8, "average_household_income_change": 1350.0455770723922, "relative_household_income_change": 1e-12, "population": 62873.80233865063 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1065.0, + "average_household_income_change_upper": -781.0, + "relative_household_income_change_lower": 0.167, + "relative_household_income_change_upper": 0.2269, "local_authority_code": "E06000000324", "local_authority_name": "Local Authority 324", - "x": 13, - "y": 10, + "x": 3, + "y": 8, "average_household_income_change": -922.7161566266906, "relative_household_income_change": 0.19694956380898854, "population": 106076.4762961037 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1157.0, + "average_household_income_change_upper": -849.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000325", "local_authority_name": "Local Authority 325", - "x": 14, - "y": 10, + "x": 4, + "y": 8, "average_household_income_change": -1002.6916131420753, "relative_household_income_change": 0.0, "population": 62774.55803733909 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1411.0, + "average_household_income_change_upper": 1917.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000326", "local_authority_name": "Local Authority 326", - "x": 15, - "y": 10, + "x": 5, + "y": 8, "average_household_income_change": 1663.6719409919683, "relative_household_income_change": -0.0, "population": 67807.47080663007 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1627.0, + "average_household_income_change_upper": -1196.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000327", "local_authority_name": "Local Authority 327", - "x": 16, - "y": 10, + "x": 6, + "y": 8, "average_household_income_change": -1411.6340938270553, "relative_household_income_change": -0.0, "population": 147665.7414411456 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1858.0, + "average_household_income_change_upper": -1367.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000328", "local_authority_name": "Local Authority 328", - "x": 17, - "y": 10, + "x": 7, + "y": 8, "average_household_income_change": -1612.4841951460103, "relative_household_income_change": 0.0, "population": 124470.9080460218 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -512.0, + "average_household_income_change_upper": -372.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000329", "local_authority_name": "Local Authority 329", - "x": 18, - "y": 10, + "x": 8, + "y": 8, "average_household_income_change": -442.1381548896619, "relative_household_income_change": -3.5e-9, "population": 122077.83443159891 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 586.0, + "average_household_income_change_upper": 801.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000330", "local_authority_name": "Local Authority 330", - "x": null, - "y": null, + "x": 9, + "y": 8, "average_household_income_change": 693.5865859513317, "relative_household_income_change": 1e-12, "population": 146696.59035836256 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -799.0, + "average_household_income_change_upper": -585.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000331", "local_authority_name": "Local Authority 331", - "x": 20, - "y": 10, + "x": 10, + "y": 8, "average_household_income_change": -692.0955580068231, "relative_household_income_change": -0.0, "population": 57876.679797211415 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1751.0, + "average_household_income_change_upper": -1288.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000332", "local_authority_name": "Local Authority 332", - "x": 21, - "y": 10, + "x": 11, + "y": 8, "average_household_income_change": -1519.174748158672, "relative_household_income_change": -0.0, "population": 95734.55317987516 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1663.0, + "average_household_income_change_upper": -1223.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000333", "local_authority_name": "Local Authority 333", - "x": 22, - "y": 10, + "x": 12, + "y": 8, "average_household_income_change": -1442.6525762495714, "relative_household_income_change": -0.0, "population": 101590.51841877076 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1518.0, + "average_household_income_change_upper": 2062.0, + "relative_household_income_change_lower": 0.6827, + "relative_household_income_change_upper": 0.9247, "local_authority_code": "E06000000334", "local_authority_name": "Local Authority 334", - "x": 23, - "y": 10, + "x": 13, + "y": 8, "average_household_income_change": 1790.1936017105982, "relative_household_income_change": 0.8036893103680613, "population": 60227.68191232477 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -760.0, + "average_household_income_change_upper": -556.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000335", "local_authority_name": "Local Authority 335", - "x": 24, - "y": 10, + "x": 14, + "y": 8, "average_household_income_change": -657.7156834947568, "relative_household_income_change": 0.0, "population": 56033.994244607464 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -111.0, + "average_household_income_change_upper": -76.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000336", "local_authority_name": "Local Authority 336", - "x": 25, - "y": 10, + "x": 15, + "y": 8, "average_household_income_change": -93.12370767006372, "relative_household_income_change": 1e-12, "population": 74251.63287615858 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -244.0, + "average_household_income_change_upper": -174.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000337", "local_authority_name": "Local Authority 337", - "x": null, - "y": null, + "x": 16, + "y": 8, "average_household_income_change": -209.0733302843737, "relative_household_income_change": 0.0, "population": 92647.79125130326 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1148.0, + "average_household_income_change_upper": 1561.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000338", "local_authority_name": "Local Authority 338", - "x": 27, - "y": 10, + "x": 17, + "y": 8, "average_household_income_change": 1354.5181812972617, "relative_household_income_change": -0.0, "population": 82370.15096479152 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -570.0, + "average_household_income_change_upper": -415.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000339", "local_authority_name": "Local Authority 339", - "x": 28, - "y": 10, + "x": 18, + "y": 8, "average_household_income_change": -492.64160858524696, "relative_household_income_change": 0.0, "population": 86770.08718112283 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1149.0, + "average_household_income_change_upper": -843.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000340", "local_authority_name": "Local Authority 340", - "x": 29, - "y": 10, + "x": 19, + "y": 8, "average_household_income_change": -995.9443815964817, "relative_household_income_change": -0.0, "population": 135610.1666875653 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -4.0, + "average_household_income_change_upper": 3.0, + "relative_household_income_change_lower": 0.4498, + "relative_household_income_change_upper": 0.6096, "local_authority_code": "E06000000341", "local_authority_name": "Local Authority 341", - "x": 30, - "y": 10, + "x": 20, + "y": 8, "average_household_income_change": -0.39287356256378914, "relative_household_income_change": 0.5297196338472642, "population": 74252.93126537 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 800.0, + "average_household_income_change_upper": 1090.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000342", "local_authority_name": "Local Authority 342", - "x": 0, - "y": 11, + "x": 21, + "y": 8, "average_household_income_change": 944.950333059178, "relative_household_income_change": 0.0, "population": 106059.18237216485 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 52.0, + "average_household_income_change_upper": 78.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000343", "local_authority_name": "Local Authority 343", - "x": 1, - "y": 11, + "x": 22, + "y": 8, "average_household_income_change": 64.77822243535725, "relative_household_income_change": 0.0, "population": 53563.437046919236 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -659.0, + "average_household_income_change_upper": -481.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000344", "local_authority_name": "Local Authority 344", - "x": null, - "y": null, + "x": 23, + "y": 8, "average_household_income_change": -570.1810329346469, "relative_household_income_change": -3.5e-9, "population": 141799.32131303017 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -176.0, + "average_household_income_change_upper": -124.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000345", "local_authority_name": "Local Authority 345", - "x": 3, - "y": 11, + "x": 24, + "y": 8, "average_household_income_change": -149.65675964463588, "relative_household_income_change": 1e-12, "population": 138491.2752178312 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1288.0, + "average_household_income_change_upper": 1751.0, + "relative_household_income_change_lower": 0.3298, + "relative_household_income_change_upper": 0.4473, "local_authority_code": "E06000000346", "local_authority_name": "Local Authority 346", - "x": 4, - "y": 11, + "x": 25, + "y": 8, "average_household_income_change": 1519.5945289635704, "relative_household_income_change": 0.3885347278781518, "population": 127943.93117060614 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": -280.0, + "average_household_income_change_upper": -201.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000347", "local_authority_name": "Local Authority 347", - "x": 5, - "y": 11, + "x": 26, + "y": 8, "average_household_income_change": -240.12834747409696, "relative_household_income_change": -0.0, "population": 148496.6980579185 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1366.0, + "average_household_income_change_upper": -1003.0, + "relative_household_income_change_lower": 0.3447, + "relative_household_income_change_upper": 0.4674, "local_authority_code": "E06000000348", "local_authority_name": "Local Authority 348", - "x": 6, - "y": 11, + "x": 27, + "y": 8, "average_household_income_change": -1184.3496615419813, "relative_household_income_change": 0.40605730599907053, "population": 52620.71908102276 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 105.0, + "average_household_income_change_upper": 151.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000349", "local_authority_name": "Local Authority 349", - "x": 7, - "y": 11, + "x": 28, + "y": 8, "average_household_income_change": 128.04545237281982, "relative_household_income_change": -0.0, "population": 145538.13077819993 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1371.0, + "average_household_income_change_upper": 1863.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000350", "local_authority_name": "Local Authority 350", - "x": 8, - "y": 11, + "x": 29, + "y": 8, "average_household_income_change": 1616.6229865208313, "relative_household_income_change": 0.0, "population": 115389.84845951825 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -162.0, + "average_household_income_change_upper": -114.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000351", "local_authority_name": "Local Authority 351", - "x": null, - "y": null, + "x": 30, + "y": 8, "average_household_income_change": -137.77954457466944, "relative_household_income_change": 1e-12, "population": 90290.34582224516 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -876.0, + "average_household_income_change_upper": -641.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000352", "local_authority_name": "Local Authority 352", - "x": 10, - "y": 11, + "x": 31, + "y": 8, "average_household_income_change": -758.6668108760035, "relative_household_income_change": 1e-12, "population": 82891.8878076119 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -509.0, + "average_household_income_change_upper": -370.0, + "relative_household_income_change_lower": 0.8476, + "relative_household_income_change_upper": 1.1478, "local_authority_code": "E06000000353", "local_authority_name": "Local Authority 353", - "x": 11, - "y": 11, + "x": 32, + "y": 8, "average_household_income_change": -439.1523756355236, "relative_household_income_change": 0.9976709354222745, "population": 59413.06245577177 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1137.0, + "average_household_income_change_upper": 1546.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000354", "local_authority_name": "Local Authority 354", - "x": 12, - "y": 11, + "x": 33, + "y": 8, "average_household_income_change": 1341.2342875125476, "relative_household_income_change": -0.0, "population": 123965.39796798336 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1544.0, + "average_household_income_change_upper": -1135.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000355", "local_authority_name": "Local Authority 355", - "x": 13, - "y": 11, + "x": 34, + "y": 8, "average_household_income_change": -1339.4924488888353, "relative_household_income_change": 0.0, "population": 138213.72660216942 }, { + "disclosure_tier": "approximate", + "average_household_income_change_lower": 1238.0, + "average_household_income_change_upper": 1684.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000356", "local_authority_name": "Local Authority 356", - "x": 14, - "y": 11, + "x": 35, + "y": 8, "average_household_income_change": 1461.0173356186356, "relative_household_income_change": -3.5e-9, "population": 109738.91820518073 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 848.0, + "average_household_income_change_upper": 1156.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000357", "local_authority_name": "Local Authority 357", - "x": 15, - "y": 11, + "x": 36, + "y": 8, "average_household_income_change": 1001.7597486741581, "relative_household_income_change": 1e-12, "population": 90421.41316384055 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": 1160.0, + "average_household_income_change_upper": 1577.0, + "relative_household_income_change_lower": -0.0005, + "relative_household_income_change_upper": 0.0004, "local_authority_code": "E06000000358", "local_authority_name": "Local Authority 358", - "x": null, - "y": null, + "x": 37, + "y": 8, "average_household_income_change": 1368.5395672388715, "relative_household_income_change": -3.5e-9, "population": 105507.23691464387 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1692.0, + "average_household_income_change_upper": -1244.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000359", "local_authority_name": "Local Authority 359", - "x": 17, - "y": 11, + "x": 38, + "y": 8, "average_household_income_change": -1467.9886853886562, "relative_household_income_change": 1e-12, "population": 109966.1632713356 }, { + "disclosure_tier": "reported", + "average_household_income_change_lower": -1696.0, + "average_household_income_change_upper": -1248.0, + "relative_household_income_change_lower": -0.0004, + "relative_household_income_change_upper": 0.0005, "local_authority_code": "E06000000360", "local_authority_name": "Local Authority 360", - "x": 18, - "y": 11, + "x": 39, + "y": 8, "average_household_income_change": -1471.8412412220912, "relative_household_income_change": 1e-12, "population": 136124.82700437307 diff --git a/libs/policyengine-simulation-persistence/tests/test_economy_results.py b/libs/policyengine-simulation-persistence/tests/test_economy_results.py index 80b7c8118..5c0c067ee 100644 --- a/libs/policyengine-simulation-persistence/tests/test_economy_results.py +++ b/libs/policyengine-simulation-persistence/tests/test_economy_results.py @@ -69,6 +69,43 @@ def test_a_stored_result_is_rebuilt_exactly( assert rebuilt == result +UK_AREA_DISCLOSURE_FIELDS = { + "disclosure_tier", + "average_household_income_change_lower", + "average_household_income_change_upper", + "relative_household_income_change_lower", + "relative_household_income_change_upper", +} + + +def test_each_uk_area_keeps_its_disclosure_tier_and_intervals( + migrated_database, public_api_rows +) -> None: + result = load_sample("uk_nation_scotland_2026") + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + _write(stores, report_run, RECORDER.validate(result.model_dump(mode="json"))) + + rebuilt = _read(migrated_database, report_run.id, labels_for(result)) + for written, read in ( + (result.constituency_impact, rebuilt.constituency_impact), + (result.local_authority_impact, rebuilt.local_authority_impact), + ): + assert written is not None + assert read is not None + assert {record.disclosure_tier for record in written.root} == { + "reported", + "approximate", + } + assert [ + record.model_dump(include=UK_AREA_DISCLOSURE_FIELDS) for record in read.root + ] == [ + record.model_dump(include=UK_AREA_DISCLOSURE_FIELDS) + for record in written.root + ] + + def test_a_reused_baseline_keeps_its_figures_once( migrated_database, public_api_rows ) -> None: diff --git a/migrations/versions/0004_economy_result_tables.py b/migrations/versions/0004_economy_result_tables.py index 6b490d218..4521120c5 100644 --- a/migrations/versions/0004_economy_result_tables.py +++ b/migrations/versions/0004_economy_result_tables.py @@ -147,6 +147,15 @@ def upgrade() -> None: sa.Column("average_household_income_change", sa.Double(), nullable=False), sa.Column("relative_household_income_change", sa.Double(), nullable=False), sa.Column("population", sa.Double(), nullable=False), + sa.Column("disclosure_tier", sa.Text(), nullable=True), + sa.Column("average_household_income_change_lower", sa.Double(), nullable=True), + sa.Column("average_household_income_change_upper", sa.Double(), nullable=True), + sa.Column("relative_household_income_change_lower", sa.Double(), nullable=True), + sa.Column("relative_household_income_change_upper", sa.Double(), nullable=True), + sa.CheckConstraint( + "disclosure_tier IS NULL OR disclosure_tier IN ('reported', 'approximate')", + name=op.f("ck_constituency_impacts_disclosure_tier"), + ), sa.CheckConstraint( "position >= 0", name=op.f("ck_constituency_impacts_position") ), @@ -291,6 +300,15 @@ def upgrade() -> None: sa.Column("average_household_income_change", sa.Double(), nullable=False), sa.Column("relative_household_income_change", sa.Double(), nullable=False), sa.Column("population", sa.Double(), nullable=False), + sa.Column("disclosure_tier", sa.Text(), nullable=True), + sa.Column("average_household_income_change_lower", sa.Double(), nullable=True), + sa.Column("average_household_income_change_upper", sa.Double(), nullable=True), + sa.Column("relative_household_income_change_lower", sa.Double(), nullable=True), + sa.Column("relative_household_income_change_upper", sa.Double(), nullable=True), + sa.CheckConstraint( + "disclosure_tier IS NULL OR disclosure_tier IN ('reported', 'approximate')", + name=op.f("ck_local_authority_impacts_disclosure_tier"), + ), sa.CheckConstraint( "position >= 0", name=op.f("ck_local_authority_impacts_position") ), From 40451a9f479c6006906c9f7a4307e6feaaa1924b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:22:13 +0400 Subject: [PATCH 084/124] Name where every field of the economy result model is stored RESULT_FIELD_STORAGE maps each field of SingleYearMacroOutput, including every field of its nested models, map values and list items, to the table column that keeps it, or to the catalog label it is read from (model and data versions; constituency and local-authority names and map positions). Fields of the other record kind in the constituency and local-authority lists are marked as refused, since check_storable refuses them. A test walks the result model's type annotations and requires the mapping to name exactly its fields, and every named column to exist, so a field added to the result model fails CI until it has a column. Co-Authored-By: Claude Opus 5.5 --- .../__init__.py | 2 + .../economy_results.py | 130 ++++++++++++++++++ .../tests/test_result_completeness.py | 76 ++++++++++ 3 files changed, 208 insertions(+) create mode 100644 libs/policyengine-simulation-persistence/tests/test_result_completeness.py diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 81f846794..872e39d75 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -6,6 +6,7 @@ from policyengine_simulation_persistence.economy_results import ( OPTIONAL_SECTIONS, + RESULT_FIELD_STORAGE, EconomyComparisonRecorder, EconomyResultLabels, GeographyLabel, @@ -62,6 +63,7 @@ "ALEMBIC_VERSION_TABLE", "HEAD_REVISION", "OPTIONAL_SECTIONS", + "RESULT_FIELD_STORAGE", "RUNTIME_ROLE", "SIMULATION_SCHEMA", "SUCCEEDED_RUN_REUSE_WINDOW", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index fb4eb23be..a14b6c343 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -32,6 +32,7 @@ from sqlalchemy.dialects.postgresql import insert as postgres_insert from policyengine_simulation_persistence.result_tables import ( + POVERTY_GROUPS, UK_AREA_DISCLOSURE_COLUMNS, budget_impacts, cliff_impacts, @@ -74,6 +75,135 @@ SIDES = ("baseline", "reform") +# Where each field of SingleYearMacroOutput is kept. "{key}" stands for a map +# key and "[]" for a list item; per-simulation figures are kept on the +# baseline's or the reform's own simulation run. Labels come from the catalog +# when the result is read. A test requires this to name every field. +REFUSED_OTHER_KIND = "refused by check_storable: a record of the other kind" +_GEOGRAPHY_FIELDS = ( + "average_household_income_change", + "relative_household_income_change", + "population", + *UK_AREA_DISCLOSURE_COLUMNS, +) + + +def _poverty_storage() -> dict[str, str]: + paths = {} + for section, measures, groups in ( + ("poverty", ("poverty", "deep_poverty"), POVERTY_GROUPS["age"]), + ("poverty_by_gender", ("poverty", "deep_poverty"), POVERTY_GROUPS["gender"]), + ("poverty_by_race", ("poverty",), POVERTY_GROUPS["race"]), + ): + for measure in measures: + for group in groups: + for side in SIDES: + paths[f"{section}.{measure}.{group}.{side}"] = "poverty_rates.rate" + return paths + + +def _geography_storage( + section: str, own: str, other: str, table: str +) -> dict[str, str]: + paths = {f"{section}[].{name}": f"{table}.{name}" for name in _GEOGRAPHY_FIELDS} + paths[f"{section}[].{own}_code"] = f"{table}.{own}_code" + paths[f"{section}[].{own}_name"] = "region catalog: name" + paths[f"{section}[].x"] = "region catalog: map x" + paths[f"{section}[].y"] = "region catalog: map y" + paths[f"{section}[].{other}_code"] = REFUSED_OTHER_KIND + paths[f"{section}[].{other}_name"] = REFUSED_OTHER_KIND + return paths + + +RESULT_FIELD_STORAGE: dict[str, str] = { + "spm_provenance": "report_runs.spm_provenance", + "model_version": "catalog version: model version", + "data_version": "catalog version: data version", + **{ + f"budget.{name}": f"budget_impacts.{name}" + for name in ( + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income", + ) + }, + **{ + f"detailed_budget.{{key}}.{name}": f"program_budget_impacts.{name}" + for name in ("baseline", "reform", "difference") + }, + **{ + f"{section}.{statistic}.{{key}}": f"decile_impacts.{statistic}_change" + for section in ("decile", "wealth_decile") + for statistic in ("average", "relative") + }, + **{ + path: "intra_decile_impacts.share" + for section in ("intra_decile", "intra_wealth_decile") + for path in (f"{section}.deciles.{{key}}[]", f"{section}.all.{{key}}") + }, + **{ + f"inequality.{name}.{side}": f"inequality.{name}" + for name in ("gini", "top_10_pct_share", "top_1_pct_share") + for side in SIDES + }, + **_poverty_storage(), + **{ + f"cliff_impact.{side}.{name}": f"cliff_impacts.{name}" + for side in SIDES + for name in ("cliff_gap", "cliff_share") + }, + **{ + f"labor_supply_response.{path}": f"labor_supply_responses.{column}" + for path, column in ( + ("substitution_lsr", "substitution_lsr"), + ("income_lsr", "income_lsr"), + ("relative_lsr.income", "relative_income_response"), + ("relative_lsr.substitution", "relative_substitution_response"), + ("total_change", "total_change"), + ("revenue_change", "revenue_change"), + ("hours.baseline", "baseline_hours"), + ("hours.reform", "reform_hours"), + ("hours.change", "hours_change"), + ("hours.income_effect", "hours_income_effect"), + ("hours.substitution_effect", "hours_substitution_effect"), + ) + }, + **{ + f"labor_supply_response.decile.{statistic}.{effect}.{{key}}": ( + f"labor_supply_decile_impacts.{statistic}_{effect}_effect" + ) + for statistic in ("average", "relative") + for effect in ("income", "substitution") + }, + **{ + f"congressional_district_impact.districts[].{name}": ( + f"congressional_district_impacts.{name}" + ) + for name in ( + "district", + "average_household_income_change", + "relative_household_income_change", + "winner_percentage", + "loser_percentage", + "no_change_percentage", + "population", + ) + }, + **_geography_storage( + "constituency_impact", "constituency", "local_authority", "constituency_impacts" + ), + **_geography_storage( + "local_authority_impact", + "local_authority", + "constituency", + "local_authority_impacts", + ), +} + + @dataclass(frozen=True) class GeographyLabel: """A constituency's or local authority's name and map position.""" diff --git a/libs/policyengine-simulation-persistence/tests/test_result_completeness.py b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py new file mode 100644 index 000000000..42828c3c1 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py @@ -0,0 +1,76 @@ +"""Every field of every result model maps to a column or a catalog label.""" + +from __future__ import annotations + +import types +import typing + +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.spm import SPMComparisonProvenance +from policyengine_simulation_persistence import ( + RESULT_FIELD_STORAGE, + simulation_metadata, +) +from policyengine_simulation_persistence.economy_results import REFUSED_OTHER_KIND +from pydantic import BaseModel, RootModel + +# Stored whole, as one JSON column. +WHOLE_VALUES = (SPMComparisonProvenance,) +CATALOG_SOURCES = { + "catalog version: model version", + "catalog version: data version", + "region catalog: name", + "region catalog: map x", + "region catalog: map y", +} + + +def _container_paths(annotation: object, path: str) -> set[str] | None: + """Return the leaf paths of a union, list or map, or None for anything else.""" + origin = typing.get_origin(annotation) + if origin in (typing.Union, types.UnionType): + return set().union( + *( + _leaf_paths(argument, path) + for argument in typing.get_args(annotation) + if argument is not type(None) + ) + ) + if origin is list: + (item,) = typing.get_args(annotation) + return _leaf_paths(item, f"{path}[]") + if origin is dict: + _, value = typing.get_args(annotation) + return _leaf_paths(value, f"{path}.{{key}}") + return None + + +def _leaf_paths(annotation: object, path: str) -> set[str]: + container = _container_paths(annotation, path) + if container is not None: + return container + if not isinstance(annotation, type) or issubclass(annotation, WHOLE_VALUES): + return {path} + if issubclass(annotation, RootModel): + return _leaf_paths(annotation.model_fields["root"].annotation, path) + if issubclass(annotation, BaseModel): + return set().union( + *( + _leaf_paths(field.annotation, f"{path}.{name}" if path else name) + for name, field in annotation.model_fields.items() + ) + ) + return {path} + + +def test_every_result_field_has_a_storage_location() -> None: + assert set(RESULT_FIELD_STORAGE) == _leaf_paths(SingleYearMacroOutput, "") + + +def test_every_storage_location_exists() -> None: + for path, location in RESULT_FIELD_STORAGE.items(): + if location in CATALOG_SOURCES or location == REFUSED_OTHER_KIND: + continue + table_name, column_name = location.split(".") + table = simulation_metadata.tables[f"simulation.{table_name}"] + assert column_name in table.c, path From 44d490493903e2dfcdb92390ced78a592c0d0492 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:25:26 +0400 Subject: [PATCH 085/124] Store a computed household as one JSON value on its simulation run A household calculation returns a nested document, so revision 0004 (now named for all result tables) adds household_outputs: one row per household simulation run with the household as PostgreSQL json, whose text, key order included, is kept as written, and its schema version. write_household_output stores it in the caller's transaction; with RunTransitions.complete_simulation_call that is the transaction marking the run succeeded, and a household report's two runs, which share one Modal call, each get their own household. It refuses an economy run and a household holding NaN or an infinity. read_household_output returns it unchanged; a test stores a sample household for both runs of a pair and reads back the same JSON and key order, and shows a failed write records no success. HouseholdOutput checks only what the table holds (a JSON object and a schema version); the household functions' own result model comes with item 1.11. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 8 ++ .../__init__.py | 8 ++ .../household_results.py | 110 +++++++++++++++++ .../household_outputs/us_household_2026.json | 53 ++++++++ .../tests/test_household_results.py | 115 ++++++++++++++++++ ...result_tables.py => 0004_result_tables.py} | 31 ++++- 6 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/household_outputs/us_household_2026.json create mode 100644 libs/policyengine-simulation-persistence/tests/test_household_results.py rename migrations/versions/{0004_economy_result_tables.py => 0004_result_tables.py} (93%) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 0b82c8e72..7efaec0e2 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -86,6 +86,14 @@ local-authority names and map positions by code. Each UK area's disclosure tier and the 90% interval bounds of its income changes are stored with its figures (null for a result computed before disclosure control). +A computed household is one JSON value (`household_outputs`, PostgreSQL +`json`, so its text, key order included, is kept as written) on its +simulation run, with its schema version. `write_household_output` stores it +in the transaction that marks the run succeeded +(`RunTransitions.complete_simulation_call`); a household report's two runs +share one Modal call and each gets its own household. `read_household_output` +returns it unchanged. + ## Ownership rules These rules are registered in `rules/registry.toml` and checked by diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 872e39d75..a86072811 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -19,6 +19,11 @@ normalized_database_url, read_schema_revision, ) +from policyengine_simulation_persistence.household_results import ( + HouseholdOutput, + read_household_output, + write_household_output, +) from policyengine_simulation_persistence.reports import ( NewReport, ReportKind, @@ -72,6 +77,7 @@ "EconomyComparisonRecorder", "EconomyResultLabels", "GeographyLabel", + "HouseholdOutput", "NewReport", "NewReportRun", "NewSimulation", @@ -95,7 +101,9 @@ "create_simulation_engine", "normalized_database_url", "read_economy_comparison", + "read_household_output", "read_schema_revision", "simulation_metadata", "write_economy_comparison", + "write_household_output", ] diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py new file mode 100644 index 000000000..7c1eeb764 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py @@ -0,0 +1,110 @@ +"""A computed household, stored as one JSON value on its simulation run. + +A household calculation returns a nested document, not a set of figures, so +``household_outputs`` keeps it whole, as PostgreSQL ``json``: the text is kept +as written, so a reader gets back the same keys in the same order and the +same numbers. ``HouseholdOutput`` checks the shape this table can hold (a JSON +object and its schema version); the household functions' own result model +(item 1.11) is validated before it reaches here. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field, JsonValue +from sqlalchemy import ( + JSON, + CheckConstraint, + Column, + ForeignKeyConstraint, + Integer, + Table, + Uuid, + insert, + select, +) + +from policyengine_simulation_persistence.runs import simulation_runs +from policyengine_simulation_persistence.schema import simulation_metadata +from policyengine_simulation_persistence.simulations import SimulationKind + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy import Connection + + from policyengine_simulation_persistence.runs import SimulationRunRecord + + +household_outputs = Table( + "household_outputs", + simulation_metadata, + Column("simulation_run_id", Uuid(as_uuid=True), primary_key=True), + Column("household", JSON(none_as_null=True), nullable=False), + Column("schema_version", Integer, nullable=False), + CheckConstraint("json_typeof(household) = 'object'", name="household"), + CheckConstraint("schema_version >= 1", name="schema_version"), + ForeignKeyConstraint( + ["simulation_run_id"], + [simulation_runs.c.id], + name="fk_household_outputs_simulation_run", + ondelete="RESTRICT", + ), +) + + +class HouseholdOutput(BaseModel): + """A computed household and the version of its document's schema.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: int = Field(ge=1) + household: dict[str, JsonValue] + + +def write_household_output( + connection: Connection, run: SimulationRunRecord, output: HouseholdOutput +) -> None: + """Store a household run's computed household in the caller's transaction. + + Raise ValueError for a run that is not a household run, or a household + holding a value JSON cannot represent (NaN or an infinity). + """ + if run.simulation_kind is not SimulationKind.HOUSEHOLD: + msg = "only a household simulation run has a computed household" + raise ValueError(msg) + try: + json.dumps(output.household, allow_nan=False) + except ValueError: + msg = "a computed household may not hold NaN or an infinity" + raise ValueError(msg) from None + connection.execute( + insert(household_outputs).values( + simulation_run_id=run.id, + household=output.household, + schema_version=output.schema_version, + ) + ) + + +def read_household_output( + connection: Connection, simulation_run_id: UUID +) -> HouseholdOutput: + """Return a run's stored household; raise LookupError when it has none.""" + row = ( + connection.execute( + select(household_outputs).where( + household_outputs.c.simulation_run_id == simulation_run_id + ) + ) + .mappings() + .first() + ) + if row is None: + msg = f"simulation run {simulation_run_id} has no computed household" + raise LookupError(msg) + return HouseholdOutput( + schema_version=row["schema_version"], household=row["household"] + ) diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/household_outputs/us_household_2026.json b/libs/policyengine-simulation-persistence/tests/fixtures/household_outputs/us_household_2026.json new file mode 100644 index 000000000..b8c3990e6 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/household_outputs/us_household_2026.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "household": { + "people": { + "you": { + "age": {"2026": 40}, + "employment_income": {"2026": 52000.5}, + "is_tax_unit_head": {"2026": true}, + "ssi": {"2026": 0.0}, + "medicaid": {"2026": -0.0}, + "marginal_tax_rate": {"2026": 0.30645000000000006} + }, + "your first dependent": { + "age": {"2026": 7}, + "employment_income": {"2026": 0}, + "is_tax_unit_dependent": {"2026": true}, + "ctc_child_individual_maximum": {"2026": 2200.0} + } + }, + "families": {"your family": {"members": ["you", "your first dependent"]}}, + "marital_units": { + "your marital unit": {"members": ["you"]}, + "your first dependent's marital unit": { + "members": ["your first dependent"], + "marital_unit_id": {"2026": 1} + } + }, + "tax_units": { + "your tax unit": { + "members": ["you", "your first dependent"], + "eitc": {"2026": 3584.123456789}, + "ctc": {"2026": 2200.0}, + "income_tax": {"2026": -1452.25}, + "state_income_tax": {"2026": null} + } + }, + "spm_units": { + "your household": { + "members": ["you", "your first dependent"], + "snap": {"2026": 0.0}, + "spm_unit_net_income": {"2026": 55410.873} + } + }, + "households": { + "your household": { + "members": ["you", "your first dependent"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": 55410.873}, + "household_benefits": {"2026": 1e-12} + } + } + } +} diff --git a/libs/policyengine-simulation-persistence/tests/test_household_results.py b/libs/policyengine-simulation-persistence/tests/test_household_results.py new file mode 100644 index 000000000..051d74e3a --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_results.py @@ -0,0 +1,115 @@ +"""A computed household stored as one JSON value and read back unchanged.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import pytest +from policyengine_simulation_persistence import ( + HouseholdOutput, + read_household_output, + write_household_output, +) +from report_support import economy_pair, household_baseline +from run_support import economy_run, household_run +from simulation_support import household_simulation, simulation_store +from transition_support import clocked_stores + +pytestmark = pytest.mark.postgres + +SAMPLE = ( + Path(__file__).parent + / "fixtures" + / "household_outputs" + / ("us_household_2026.json") +) + + +def _sample() -> HouseholdOutput: + return HouseholdOutput.model_validate_json(SAMPLE.read_text(encoding="utf-8")) + + +def _running_household_pair(stores, database, rows): + baseline = household_baseline(database, rows) + reform = simulation_store(database).create( + household_simulation(rows, policy_id=rows.us_policy_id) + ) + runs = [ + stores.runs.start_simulation_run(household_run(simulation, rows)).run + for simulation in (baseline, reform) + ] + run_ids = [run.id for run in runs] + stores.transitions.claim_simulation_dispatch(run_ids) + stores.transitions.record_simulation_dispatched( + run_ids, modal_call_id="fc-household", first_check_at=stores.clock() + ) + return runs + + +def test_a_household_pair_is_stored_with_its_success_and_read_back_unchanged( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + runs = _running_household_pair(stores, migrated_database, public_api_rows) + output = _sample() + + def write_both(connection, succeeded_runs) -> None: + for run in succeeded_runs: + write_household_output(connection, run, output) + + assert stores.transitions.complete_simulation_call("fc-household", write_both) + + with stores.transitions.engine.connect() as connection: + for run in runs: + stored = read_household_output(connection, run.id) + assert stored.model_dump_json() == output.model_dump_json() + assert list(stored.household) == list( + json.loads(SAMPLE.read_text())["household"] + ) + + +def test_a_failed_household_write_records_no_success( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + runs = _running_household_pair(stores, migrated_database, public_api_rows) + broken = HouseholdOutput(schema_version=1, household={"people": math.nan}) + + def write_both(connection, succeeded_runs) -> None: + for run in succeeded_runs: + write_household_output(connection, run, broken) + + with pytest.raises(ValueError, match="NaN"): + stores.transitions.complete_simulation_call("fc-household", write_both) + + assert {stores.runs.get_simulation_run(run.id).status.value for run in runs} == { + "running" + } + with ( + stores.transitions.engine.connect() as connection, + pytest.raises(LookupError, match="no computed household"), + ): + read_household_output(connection, runs[0].id) + + +def test_only_a_household_run_has_a_computed_household( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + baseline, _ = economy_pair(migrated_database, public_api_rows) + run = stores.runs.start_simulation_run(economy_run(baseline, public_api_rows)).run + + with ( + stores.transitions.engine.begin() as connection, + pytest.raises(ValueError, match="household simulation run"), + ): + write_household_output(connection, run, _sample()) + + +def test_a_household_output_is_a_json_object_with_a_schema_version() -> None: + with pytest.raises(ValueError, match="schema_version"): + HouseholdOutput(schema_version=0, household={}) + with pytest.raises(ValueError, match="household"): + HouseholdOutput.model_validate({"schema_version": 1, "household": ["people"]}) diff --git a/migrations/versions/0004_economy_result_tables.py b/migrations/versions/0004_result_tables.py similarity index 93% rename from migrations/versions/0004_economy_result_tables.py rename to migrations/versions/0004_result_tables.py index 4521120c5..d7bb7711c 100644 --- a/migrations/versions/0004_economy_result_tables.py +++ b/migrations/versions/0004_result_tables.py @@ -1,7 +1,8 @@ -"""Add the economy result tables. +"""Add the result tables. Every economy result is stored as rows, each section of today's result model -in its own table. A simulation's own figures (poverty rates, inequality, cliff +in its own table, and a computed household as one JSON value on its +simulation run. A simulation's own figures (poverty rates, inequality, cliff exposure) belong to its simulation run; the comparison of a baseline and a reform (budget, programs, deciles, labour supply, geographies) belongs to the report run. Each report run also records the optional sections its result @@ -26,8 +27,33 @@ depends_on = None +def _create_household_outputs() -> None: + op.create_table( + "household_outputs", + sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("household", sa.JSON(none_as_null=True), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False), + sa.CheckConstraint( + "json_typeof(household) = 'object'", + name=op.f("ck_household_outputs_household"), + ), + sa.CheckConstraint( + "schema_version >= 1", name=op.f("ck_household_outputs_schema_version") + ), + sa.ForeignKeyConstraint( + ["simulation_run_id"], + ["simulation.simulation_runs.id"], + name="fk_household_outputs_simulation_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("simulation_run_id", name=op.f("pk_household_outputs")), + schema="simulation", + ) + + def upgrade() -> None: """Create the result tables and the report run's result columns.""" + _create_household_outputs() op.create_table( "cliff_impacts", sa.Column("simulation_run_id", sa.Uuid(), nullable=False), @@ -399,3 +425,4 @@ def downgrade() -> None: op.drop_table("poverty_rates", schema="simulation") op.drop_table("inequality", schema="simulation") op.drop_table("cliff_impacts", schema="simulation") + op.drop_table("household_outputs", schema="simulation") From ab69d959ac270eb826892d69c5252fcd2a53651e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:43:33 +0400 Subject: [PATCH 086/124] Link each run to the files it stored, offered for 30 days Revision 0004 adds output_files: a stored file's role, URI and SHA-256, belonging to exactly one simulation run or report run (a check enforces it), unique per run and URI. record_output_files records links in the caller's transaction; available_output_files offers a run's links written less than 30 days ago (OUTPUT_FILE_RETENTION), since the artifact bucket deletes files after 30 days while result rows stay for good. OutputFile refuses a malformed role, URI or digest before any write, and the database checks them again. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 5 + .../__init__.py | 12 ++ .../output_files.py | 176 ++++++++++++++++++ .../tests/test_output_files.py | 111 +++++++++++ migrations/versions/0004_result_tables.py | 66 ++++++- 5 files changed, 362 insertions(+), 8 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/output_files.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_output_files.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 7efaec0e2..308c6b69f 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -94,6 +94,11 @@ in the transaction that marks the run succeeded share one Modal call and each gets its own household. `read_household_output` returns it unchanged. +`output_files` links each stored file (its role, URI and SHA-256) to the one +simulation run or report run that wrote it. `available_output_files` offers a +link for 30 days after its file was written (`OUTPUT_FILE_RETENTION`), as long +as the artifact bucket keeps it; result rows stay for good. + ## Ownership rules These rules are registered in `rules/registry.toml` and checked by diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index a86072811..2bee7a378 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -24,6 +24,13 @@ read_household_output, write_household_output, ) +from policyengine_simulation_persistence.output_files import ( + OUTPUT_FILE_RETENTION, + OutputFile, + OutputFileRecord, + available_output_files, + record_output_files, +) from policyengine_simulation_persistence.reports import ( NewReport, ReportKind, @@ -68,6 +75,7 @@ "ALEMBIC_VERSION_TABLE", "HEAD_REVISION", "OPTIONAL_SECTIONS", + "OUTPUT_FILE_RETENTION", "RESULT_FIELD_STORAGE", "RUNTIME_ROLE", "SIMULATION_SCHEMA", @@ -82,6 +90,8 @@ "NewReportRun", "NewSimulation", "NewSimulationRun", + "OutputFile", + "OutputFileRecord", "ReportKind", "ReportRecord", "ReportRunRecord", @@ -97,12 +107,14 @@ "SimulationRunRecord", "SimulationStore", "StartedSimulationRun", + "available_output_files", "check_storable", "create_simulation_engine", "normalized_database_url", "read_economy_comparison", "read_household_output", "read_schema_revision", + "record_output_files", "simulation_metadata", "write_economy_comparison", "write_household_output", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/output_files.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/output_files.py new file mode 100644 index 000000000..e5da03ea7 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/output_files.py @@ -0,0 +1,176 @@ +"""Links to the files a run stored, each belonging to one run. + +A simulation run may store its microdata and a report run its comparison +files in the artifact bucket, which deletes them after 30 days. A link is +offered for 30 days after its file was written; the result rows stay for +good. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import timedelta +from typing import TYPE_CHECKING +from uuid import uuid4 + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + ForeignKeyConstraint, + String, + Table, + Text, + UniqueConstraint, + Uuid, + insert, + select, +) + +from policyengine_simulation_persistence.runs import report_runs, simulation_runs +from policyengine_simulation_persistence.schema import simulation_metadata + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import datetime + from uuid import UUID + + from sqlalchemy import Connection + +OUTPUT_FILE_RETENTION = timedelta(days=30) +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +URI_PATTERN = re.compile(r"[a-z][a-z0-9+.-]*://\S+") +ROLE_PATTERN = re.compile(r"[a-z][a-z0-9_]{0,63}") + +output_files = Table( + "output_files", + simulation_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("simulation_run_id", Uuid(as_uuid=True), nullable=True), + Column("report_run_id", Uuid(as_uuid=True), nullable=True), + Column("role", Text, nullable=False), + Column("uri", Text, nullable=False), + Column("sha256", String(64), nullable=False), + Column("written_at", DateTime(timezone=True), nullable=False), + CheckConstraint( + "(simulation_run_id IS NULL) <> (report_run_id IS NULL)", + name="one_run", + ), + CheckConstraint("sha256 ~ '^[0-9a-f]{64}$'", name="sha256"), + CheckConstraint("uri ~ '^[a-z][a-z0-9+.-]*://\\S+$'", name="uri"), + CheckConstraint("role ~ '^[a-z][a-z0-9_]{0,63}$'", name="role"), + UniqueConstraint("simulation_run_id", "uri"), + UniqueConstraint("report_run_id", "uri"), + ForeignKeyConstraint( + ["simulation_run_id"], + [simulation_runs.c.id], + name="fk_output_files_simulation_run", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["report_run_id"], + [report_runs.c.id], + name="fk_output_files_report_run", + ondelete="RESTRICT", + ), +) + + +@dataclass(frozen=True) +class OutputFile: + """A stored file: what it holds (its role), where it is and its SHA-256.""" + + role: str + uri: str + sha256: str + + def __post_init__(self) -> None: + """Reject a malformed link without touching the database.""" + for field_name, pattern in ( + ("role", ROLE_PATTERN), + ("uri", URI_PATTERN), + ("sha256", SHA256_PATTERN), + ): + if not pattern.fullmatch(getattr(self, field_name)): + msg = f"{field_name} is malformed" + raise ValueError(msg) + + +@dataclass(frozen=True) +class OutputFileRecord: + """One ``simulation.output_files`` row.""" + + id: UUID + simulation_run_id: UUID | None + report_run_id: UUID | None + role: str + uri: str + sha256: str + written_at: datetime + + def as_output_file(self) -> OutputFile: + """Return the link without its row's identity.""" + return OutputFile(role=self.role, uri=self.uri, sha256=self.sha256) + + +def _owner( + simulation_run_id: UUID | None, report_run_id: UUID | None +) -> dict[str, UUID | None]: + if (simulation_run_id is None) == (report_run_id is None): + msg = "a file belongs to exactly one run: a simulation run or a report run" + raise ValueError(msg) + return {"simulation_run_id": simulation_run_id, "report_run_id": report_run_id} + + +def record_output_files( + connection: Connection, + files: Sequence[OutputFile], + *, + written_at: datetime, + simulation_run_id: UUID | None = None, + report_run_id: UUID | None = None, +) -> None: + """Record links to files one run stored, in the caller's transaction.""" + owner = _owner(simulation_run_id, report_run_id) + if files: + connection.execute( + insert(output_files), + [ + { + "id": uuid4(), + **owner, + "role": file.role, + "uri": file.uri, + "sha256": file.sha256, + "written_at": written_at, + } + for file in files + ], + ) + + +def available_output_files( + connection: Connection, + *, + now: datetime, + simulation_run_id: UUID | None = None, + report_run_id: UUID | None = None, +) -> list[OutputFileRecord]: + """Return one run's files written less than 30 days before ``now``.""" + owner = _owner(simulation_run_id, report_run_id) + owner_column = ( + output_files.c.simulation_run_id + if owner["simulation_run_id"] is not None + else output_files.c.report_run_id + ) + owner_id = owner["simulation_run_id"] or owner["report_run_id"] + rows = connection.execute( + select(output_files) + .where( + owner_column == owner_id, + output_files.c.written_at > now - OUTPUT_FILE_RETENTION, + ) + .order_by(output_files.c.written_at, output_files.c.role, output_files.c.uri) + ).mappings() + return [OutputFileRecord(**row) for row in rows] diff --git a/libs/policyengine-simulation-persistence/tests/test_output_files.py b/libs/policyengine-simulation-persistence/tests/test_output_files.py new file mode 100644 index 000000000..0b4de483b --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_output_files.py @@ -0,0 +1,111 @@ +"""Links to a run's stored files, offered for 30 days after they were written.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest +from policyengine_simulation_persistence import ( + OUTPUT_FILE_RETENTION, + OutputFile, + available_output_files, + record_output_files, +) +from result_support import comparison_ready, result_stores +from sqlalchemy.exc import IntegrityError + +pytestmark = pytest.mark.postgres + +SHA256 = "ab" * 32 +MICRODATA = OutputFile( + role="microdata", uri="gs://simulation-artifacts/run/baseline.h5", sha256=SHA256 +) +COMPARISON = OutputFile( + role="comparison", + uri="gs://simulation-artifacts/run/comparison.json", + sha256=SHA256, +) + + +def test_a_run_offers_its_files_for_thirty_days( + migrated_database, public_api_rows +) -> None: + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + written_at = stores.clock() + with stores.transitions.engine.begin() as connection: + record_output_files( + connection, + [MICRODATA], + written_at=written_at, + simulation_run_id=report_run.baseline_simulation_run_id, + ) + record_output_files( + connection, [COMPARISON], written_at=written_at, report_run_id=report_run.id + ) + + with stores.transitions.engine.connect() as connection: + baseline_files = available_output_files( + connection, + now=written_at + OUTPUT_FILE_RETENTION - timedelta(seconds=1), + simulation_run_id=report_run.baseline_simulation_run_id, + ) + comparison_files = available_output_files( + connection, now=written_at, report_run_id=report_run.id + ) + expired = available_output_files( + connection, + now=written_at + OUTPUT_FILE_RETENTION, + simulation_run_id=report_run.baseline_simulation_run_id, + ) + + assert [file.as_output_file() for file in baseline_files] == [MICRODATA] + assert [file.as_output_file() for file in comparison_files] == [COMPARISON] + assert baseline_files[0].written_at == written_at + assert expired == [] + assert timedelta(days=30) == OUTPUT_FILE_RETENTION + + +def test_a_file_belongs_to_exactly_one_run(migrated_database, public_api_rows) -> None: + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + with ( + stores.transitions.engine.begin() as connection, + pytest.raises(ValueError, match="exactly one run"), + ): + record_output_files( + connection, + [MICRODATA], + written_at=stores.clock(), + simulation_run_id=report_run.baseline_simulation_run_id, + report_run_id=report_run.id, + ) + + +def test_a_run_links_each_file_once(migrated_database, public_api_rows) -> None: + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + with ( + pytest.raises(IntegrityError, match="uq_output_files"), + stores.transitions.engine.begin() as connection, + ): + record_output_files( + connection, + [COMPARISON, COMPARISON], + written_at=stores.clock(), + report_run_id=report_run.id, + ) + + +@pytest.mark.parametrize( + ("field_name", "value"), + [("sha256", "not-a-digest"), ("uri", "baseline.h5"), ("role", "")], +) +def test_a_malformed_file_link_is_refused(field_name, value) -> None: + values = {"role": "microdata", "uri": "gs://bucket/file.h5", "sha256": SHA256} + values[field_name] = value + + with pytest.raises(ValueError, match=field_name): + OutputFile(**values) diff --git a/migrations/versions/0004_result_tables.py b/migrations/versions/0004_result_tables.py index d7bb7711c..feded21e0 100644 --- a/migrations/versions/0004_result_tables.py +++ b/migrations/versions/0004_result_tables.py @@ -1,14 +1,14 @@ """Add the result tables. Every economy result is stored as rows, each section of today's result model -in its own table, and a computed household as one JSON value on its -simulation run. A simulation's own figures (poverty rates, inequality, cliff -exposure) belong to its simulation run; the comparison of a baseline and a -reform (budget, programs, deciles, labour supply, geographies) belongs to the -report run. Each report run also records the optional sections its result -holds and its SPM provenance. Rows are written in the transaction that marks a -run succeeded; the default privileges let the runtime role read and insert -them. Nothing writes them yet. +in its own table, a computed household as one JSON value on its simulation +run, and links to each run's stored files. A simulation's own figures +(poverty rates, inequality, cliff exposure) belong to its simulation run; the +comparison of a baseline and a reform (budget, programs, deciles, labour +supply, geographies) belongs to the report run. Each report run also records +the optional sections its result holds and its SPM provenance. Rows are +written in the transaction that marks a run succeeded; the default privileges +let the runtime role read and insert them. Nothing writes them yet. Revision ID: 0004 Revises: 0003 @@ -51,9 +51,58 @@ def _create_household_outputs() -> None: ) +def _create_output_files() -> None: + op.create_table( + "output_files", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("simulation_run_id", sa.Uuid(), nullable=True), + sa.Column("report_run_id", sa.Uuid(), nullable=True), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("uri", sa.Text(), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("written_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "(simulation_run_id IS NULL) <> (report_run_id IS NULL)", + name=op.f("ck_output_files_one_run"), + ), + sa.CheckConstraint( + "sha256 ~ '^[0-9a-f]{64}$'", name=op.f("ck_output_files_sha256") + ), + sa.CheckConstraint( + "uri ~ '^[a-z][a-z0-9+.-]*://\\S+$'", name=op.f("ck_output_files_uri") + ), + sa.CheckConstraint( + "role ~ '^[a-z][a-z0-9_]{0,63}$'", name=op.f("ck_output_files_role") + ), + sa.ForeignKeyConstraint( + ["simulation_run_id"], + ["simulation.simulation_runs.id"], + name="fk_output_files_simulation_run", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_output_files_report_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_output_files")), + sa.UniqueConstraint( + "simulation_run_id", + "uri", + name=op.f("uq_output_files_simulation_run_id_uri"), + ), + sa.UniqueConstraint( + "report_run_id", "uri", name=op.f("uq_output_files_report_run_id_uri") + ), + schema="simulation", + ) + + def upgrade() -> None: """Create the result tables and the report run's result columns.""" _create_household_outputs() + _create_output_files() op.create_table( "cliff_impacts", sa.Column("simulation_run_id", sa.Uuid(), nullable=False), @@ -426,3 +475,4 @@ def downgrade() -> None: op.drop_table("inequality", schema="simulation") op.drop_table("cliff_impacts", schema="simulation") op.drop_table("household_outputs", schema="simulation") + op.drop_table("output_files", schema="simulation") From a56ee3e7b545c40b2227abbc4c5d0072ed56f65d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:44:26 +0400 Subject: [PATCH 087/124] Measure writing, reading and storing the result sample A PostgreSQL test writes each sample result through the comparison recorder in the transaction that marks its comparison succeeded, reads it back exactly, and prints each write and read time and the result tables' storage, with no thresholds. Locally the eight results took 25 to 210 ms to write, 20 to 140 ms to read, and about 950 KiB in all. The same measurements against the staging database, with its load, are an operator step. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_result_measurements.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 libs/policyengine-simulation-persistence/tests/test_result_measurements.py diff --git a/libs/policyengine-simulation-persistence/tests/test_result_measurements.py b/libs/policyengine-simulation-persistence/tests/test_result_measurements.py new file mode 100644 index 000000000..4a8f4b8ae --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_result_measurements.py @@ -0,0 +1,82 @@ +"""Write time, read time and storage of the result sample, as measurements. + +Each sample result is written and read back through the runtime role, and +the times and the result tables' sizes are printed. Nothing depends on them: +the numbers are recorded, with no thresholds. The same measurements against +the staging database are an operator step. +""" + +from __future__ import annotations + +import time + +import pytest +from policyengine_simulation_persistence import ( + EconomyComparisonRecorder, + read_economy_comparison, +) +from policyengine_simulation_persistence.result_tables import ECONOMY_RESULT_TABLES +from public_api_support import superuser_engine +from result_support import ( + SAMPLE_NAMES, + comparison_ready, + labels_for, + load_sample, + result_stores, +) +from sqlalchemy import text + +pytestmark = pytest.mark.postgres + + +def _table_bytes(database) -> int: + engine = superuser_engine(database.superuser_url) + try: + with engine.connect() as connection: + return sum( + connection.execute( + text("SELECT pg_total_relation_size(:table)"), + {"table": f"simulation.{table.name}"}, + ).scalar_one() + for table in ECONOMY_RESULT_TABLES + ) + finally: + engine.dispose() + + +def test_measure_writing_and_reading_every_sample( + migrated_database, public_api_rows, capsys +) -> None: + stores = result_stores(migrated_database) + recorder = EconomyComparisonRecorder() + empty_bytes = _table_bytes(migrated_database) + lines = [] + for name in SAMPLE_NAMES: + result = load_sample(name) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + started = time.perf_counter() + stores.transitions.complete_comparison( + report_run.id, + lambda connection, record, result=result: recorder.write( + connection, record, result + ), + ) + written = time.perf_counter() + with stores.transitions.engine.connect() as connection: + rebuilt = read_economy_comparison( + connection, report_run.id, labels_for(result) + ) + read = time.perf_counter() + assert rebuilt == result + lines.append( + f" {name}: write {1000 * (written - started):.1f} ms, " + f"read {1000 * (read - written):.1f} ms" + ) + stored_bytes = _table_bytes(migrated_database) - empty_bytes + with capsys.disabled(): + print("\nresult tables measurements:") # noqa: T201 # the measurement this test records + for measurement in lines: + print(measurement) # noqa: T201 # the measurement this test records + print( # noqa: T201 # the measurement this test records + f" storage for {len(SAMPLE_NAMES)} results: {stored_bytes / 1024:.0f} KiB" + ) From 55e3edec8d28f26fbbe471f5b758c6f12201b3e1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:08:36 +0400 Subject: [PATCH 088/124] Rebuild every result exactly: per-comparison figures, fallback shapes, empty maps Fixes from the first review of the result tables: - A reused simulation run kept the first comparison's own figures (written with ON CONFLICT DO NOTHING), so a later report run read poverty rates, inequality and cliff figures it had not produced; they can differ between comparisons, for example US poverty rates under each comparison's SPM selection, or zeros where a breakdown failed. poverty_rates, inequality and cliff_impacts are now keyed by the report run as well as the simulation run, and each report run reads its own. - check_storable refused the intra-decile breakdown today's executor emits when the calculation fails (each category with no decile shares). It now requires only that the shares and the averages name the same categories in the same order, and the reader rebuilds a category with no shares as an empty list. - An empty decile map was read back as null (wealth) or could not be read at all (income); it is now rebuilt as written. - A result naming an area twice passed the checks and then failed its write on the primary key, which the recording loop would retry until Modal discarded the output. check_storable now refuses it up front. - Result rows are written once: revision 0004 revokes UPDATE on the result tables from the runtime role (the README said so already), and the per-simulation tables get an index on their simulation run. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 8 +- .../economy_results.py | 159 +++++++++++------- .../result_tables.py | 31 +++- .../tests/test_economy_results.py | 101 ++++++++++- .../tests/test_schema.py | 22 ++- migrations/versions/0004_result_tables.py | 66 +++++++- 6 files changed, 309 insertions(+), 78 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 308c6b69f..da24545d1 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -70,10 +70,14 @@ section of today's result model, `SingleYearMacroOutput`: | Belongs to | Tables | | --- | --- | -| A simulation run (its own figures, kept once when the run is reused) | `poverty_rates`, `inequality`, `cliff_impacts` | +| A simulation run (its own figures, as each report run's comparison computed them) | `poverty_rates`, `inequality`, `cliff_impacts` | | A report run (the comparison) | `budget_impacts`, `program_budget_impacts`, `decile_impacts` (income and wealth), `intra_decile_impacts` (income and wealth), `labor_supply_responses`, `labor_supply_decile_impacts`, `congressional_district_impacts`, `constituency_impacts`, `local_authority_impacts` | -Lists and maps keep their order in a `position` column. The report run also +A reused simulation run's own figures are keyed by the report run too, +because they can differ between comparisons (US poverty rates follow each +comparison's SPM selection). Lists and maps keep their order in a `position` +column. Result rows are written once and never changed: the runtime role may +read and insert them but not update or delete them. The report run also records which optional sections its result holds (`result_sections`) and its SPM provenance. `write_economy_comparison` checks the result can be held exactly (`check_storable`) and writes its rows in the transaction that marks diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index a14b6c343..5d900d7f6 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -10,9 +10,9 @@ A result the tables cannot hold exactly is refused before any write by ``check_storable``: the average and relative changes of each decile map must -name the same deciles in the same order, each decile's category share list -must hold at least one value, and the constituency and local-authority lists -must hold records of their own kind. +name the same deciles in the same order, the decile shares and averages of +each intra-decile breakdown must name the same categories in the same order, +and the area lists must hold records of their own kind, each area once. """ from __future__ import annotations @@ -237,8 +237,16 @@ def _check_decile_maps(name: str, decile: DecileOutput | None) -> None: def _check_intra_decile(name: str, intra: IntraDecileOutput | None) -> None: - if intra is not None and any(not shares for shares in intra.deciles.values()): - msg = f"{name}: every category needs at least one decile share" + # Today's executor gives each category's decile shares (possibly none, + # when the breakdown failed) and its average under the same categories. + if intra is not None and list(intra.deciles) != list(intra.all): + msg = f"{name}: deciles and all must name the same categories in order" + raise ValueError(msg) + + +def _check_unique(section: str, codes: list[str]) -> None: + if len(codes) != len(set(codes)): + msg = f"{section} repeats an area" raise ValueError(msg) @@ -272,11 +280,21 @@ def check_storable(result: SingleYearMacroOutput) -> SingleYearMacroOutput: ("local_authority_impact", LocalAuthorityImpactRecord), ): records = getattr(result, section) - if records is not None and not all( - isinstance(record, record_type) for record in records.root - ): + if records is None: + continue + if not all(isinstance(record, record_type) for record in records.root): msg = f"{section} must hold only {record_type.__name__} records" raise ValueError(msg) + code_field = f"{section.removesuffix('_impact')}_code" + _check_unique(section, [getattr(record, code_field) for record in records.root]) + if result.congressional_district_impact is not None: + _check_unique( + "congressional_district_impact", + [ + record.district + for record in result.congressional_district_impact.districts + ], + ) return result @@ -288,20 +306,8 @@ def _insert(connection: Connection, table: Table, rows: list[dict[str, Any]]) -> connection.execute(postgres_insert(table), rows) -def _insert_once( - connection: Connection, table: Table, rows: list[dict[str, Any]] -) -> None: - """Insert one simulation run's rows unless an earlier comparison wrote them. - - A reused simulation run, most often a current-law baseline, takes part in - many comparisons; its own figures are the same in each. - """ - if rows: - connection.execute(postgres_insert(table).on_conflict_do_nothing(), rows) - - def _simulation_rows( - result: SingleYearMacroOutput, side: str, run_id: UUID + result: SingleYearMacroOutput, side: str, run_id: UUID, report_run_id: UUID ) -> dict[Table, list[dict[str, Any]]]: poverty = [] groups = [ @@ -315,6 +321,7 @@ def _simulation_rows( for dimension, rates, measure in groups: poverty.extend( { + "report_run_id": report_run_id, "simulation_run_id": run_id, "measure": measure, "group_dimension": dimension, @@ -327,6 +334,7 @@ def _simulation_rows( poverty_rates: poverty, inequality: [ { + "report_run_id": report_run_id, "simulation_run_id": run_id, **{name: getattr(value, side) for name, value in result.inequality}, } @@ -334,7 +342,13 @@ def _simulation_rows( } if result.cliff_impact is not None: cliff = getattr(result.cliff_impact, side) - rows[cliff_impacts] = [{"simulation_run_id": run_id, **cliff.model_dump()}] + rows[cliff_impacts] = [ + { + "report_run_id": report_run_id, + "simulation_run_id": run_id, + **cliff.model_dump(), + } + ] return rows @@ -504,9 +518,10 @@ def write_economy_comparison( ) -> None: """Write an economy report run's result rows in the caller's transaction. - The figures of each simulation go to its simulation run, the comparison to - the report run, and the SPM provenance and the optional sections the - result holds to the report run's own columns. + The figures of each simulation go to its simulation run, keyed by this + report run too; the comparison goes to the report run, and the SPM + provenance and the optional sections the result holds to the report run's + own columns. """ check_storable(result) if report_run.reform_simulation_run_id is None: @@ -517,8 +532,10 @@ def write_economy_comparison( report_run.reform_simulation_run_id, ) for side, run_id in zip(SIDES, run_ids, strict=True): - for table, rows in _simulation_rows(result, side, run_id).items(): - _insert_once(connection, table, rows) + for table, rows in _simulation_rows( + result, side, run_id, report_run.id + ).items(): + _insert(connection, table, rows) for table, rows in _comparison_rows(report_run.id, result).items(): _insert(connection, table, rows) connection.execute( @@ -606,18 +623,45 @@ def _poverty(rows_by_side: dict[str, list[RowMapping]]) -> dict[str, Any]: } +def _figures( + connection: Connection, table: Table, report_run_id: UUID, run_id: UUID +) -> list[RowMapping]: + """Return one simulation run's figures as one report run computed them.""" + return list( + connection.execute( + select(table).where( + table.c.report_run_id == report_run_id, + table.c.simulation_run_id == run_id, + ) + ).mappings() + ) + + +def _one_figure( + connection: Connection, table: Table, report_run_id: UUID, run_id: UUID +) -> RowMapping: + rows = _figures(connection, table, report_run_id, run_id) + if len(rows) != 1: + msg = f"{table.name} has no row for run {run_id} in report run {report_run_id}" + raise LookupError(msg) + return rows[0] + + def _simulation_sections( - connection: Connection, run_ids: tuple[UUID, UUID], sections: set[str] + connection: Connection, + report_run_id: UUID, + run_ids: tuple[UUID, UUID], + sections: set[str], ) -> dict[str, Any]: by_side = { - side: _rows(connection, poverty_rates, "simulation_run_id", run_id) + side: _figures(connection, poverty_rates, report_run_id, run_id) for side, run_id in zip(SIDES, run_ids, strict=True) } payload = _poverty(by_side) if "poverty_by_race" not in sections: payload["poverty_by_race"] = None inequality_rows = [ - _one(connection, inequality, "simulation_run_id", run_id) for run_id in run_ids + _one_figure(connection, inequality, report_run_id, run_id) for run_id in run_ids ] payload["inequality"] = { name: { @@ -625,30 +669,21 @@ def _simulation_sections( } for name in ("gini", "top_10_pct_share", "top_1_pct_share") } - payload["cliff_impact"] = ( - { + payload["cliff_impact"] = None + if "cliff_impact" in sections: + cliff_rows = [ + _one_figure(connection, cliff_impacts, report_run_id, run_id) + for run_id in run_ids + ] + payload["cliff_impact"] = { side: {name: row[name] for name in ("cliff_gap", "cliff_share")} - for side, row in zip( - SIDES, - ( - _one(connection, cliff_impacts, "simulation_run_id", run_id) - for run_id in run_ids - ), - strict=True, - ) + for side, row in zip(SIDES, cliff_rows, strict=True) } - if "cliff_impact" in sections - else None - ) return payload -def _deciles( - rows: list[RowMapping], measure: str -) -> dict[str, dict[str, float]] | None: +def _deciles(rows: list[RowMapping], measure: str) -> dict[str, dict[str, float]]: measured = [row for row in rows if row["measure"] == measure] - if not measured: - return None return { "average": {row["decile"]: row["average_change"] for row in measured}, "relative": {row["decile"]: row["relative_change"] for row in measured}, @@ -656,22 +691,25 @@ def _deciles( def _intra_deciles(rows: list[RowMapping], measure: str) -> dict[str, Any]: + """Rebuild one breakdown; its categories come from their average rows. + + A category whose decile shares are missing (the breakdown failed) gets an + empty list, as it was written. + """ measured = [row for row in rows if row["measure"] == measure] - deciles: dict[str, list[float]] = {} + averages = sorted( + (row for row in measured if row["decile"] is None), + key=lambda row: row["outcome_position"], + ) + deciles: dict[str, list[float]] = {row["outcome"]: [] for row in averages} for row in sorted( (row for row in measured if row["decile"] is not None), key=lambda row: (row["outcome_position"], row["decile"]), ): - deciles.setdefault(row["outcome"], []).append(row["share"]) + deciles[row["outcome"]].append(row["share"]) return { "deciles": deciles, - "all": { - row["outcome"]: row["share"] - for row in sorted( - (row for row in measured if row["decile"] is None), - key=lambda row: row["outcome_position"], - ) - }, + "all": {row["outcome"]: row["share"] for row in averages}, } @@ -714,7 +752,10 @@ def _labor_supply(connection: Connection, report_run_id: UUID) -> dict[str, Any] def _labelled_geographies( - rows: list[RowMapping], code_field: str, name_field: str, labels: Mapping + rows: list[RowMapping], + code_field: str, + name_field: str, + labels: Mapping[str, GeographyLabel], ) -> list[dict[str, Any]]: records = [] for row in rows: @@ -873,6 +914,6 @@ def read_economy_comparison( "model_version": labels.model_version, "data_version": labels.data_version, **_comparison_sections(connection, report_run_id, sections, labels), - **_simulation_sections(connection, run_ids, sections), + **_simulation_sections(connection, report_run_id, run_ids, sections), } ) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py index 4a7cee744..955c72cd0 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -3,8 +3,10 @@ Each section of today's economy result model (``SingleYearMacroOutput``) maps to one table. Numbers that describe one simulation (a baseline's or a reform's own poverty rates, inequality and cliff exposure) belong to that simulation -run, so a reused current-law baseline keeps them once; comparisons (the change -between baseline and reform) belong to the report run. Lists and maps keep +run, as computed by one comparison: they are keyed by the simulation run and +the report run, because a reused run's figures can differ between comparisons. +Comparisons (the change between baseline and reform) belong to the report +run. Lists and maps keep their order in a ``position`` column, so a reader rebuilds the result exactly. Constituency and local-authority names and map positions are not stored: they @@ -25,6 +27,7 @@ Column, Double, ForeignKeyConstraint, + Index, Integer, PrimaryKeyConstraint, SmallInteger, @@ -62,14 +65,28 @@ def _values(values: tuple[str, ...]) -> str: def _simulation_run_key(table_name: str) -> list[SchemaItem]: + """Key a simulation's own figures by its run and the comparison that computed them. + + A reused simulation run takes part in many comparisons, and its figures + can differ between them (US poverty rates follow each comparison's SPM + selection), so each report run keeps its own copy. + """ return [ + Column("report_run_id", Uuid(as_uuid=True), nullable=False), Column("simulation_run_id", Uuid(as_uuid=True), nullable=False), + ForeignKeyConstraint( + ["report_run_id"], + [report_runs.c.id], + name=f"fk_{table_name}_report_run", + ondelete="RESTRICT", + ), ForeignKeyConstraint( ["simulation_run_id"], [simulation_runs.c.id], name=f"fk_{table_name}_simulation_run", ondelete="RESTRICT", ), + Index(f"ix_{table_name}_simulation_run_id", "simulation_run_id"), ] @@ -124,7 +141,11 @@ def _uk_area_disclosure() -> list[SchemaItem]: Column("group_value", Text, nullable=False), *_numbers("rate"), PrimaryKeyConstraint( - "simulation_run_id", "measure", "group_dimension", "group_value" + "report_run_id", + "simulation_run_id", + "measure", + "group_dimension", + "group_value", ), CheckConstraint(f"measure IN ({_values(POVERTY_MEASURES)})", name="measure"), CheckConstraint( @@ -142,7 +163,7 @@ def _uk_area_disclosure() -> list[SchemaItem]: simulation_metadata, *_simulation_run_key("inequality"), *_numbers("gini", "top_10_pct_share", "top_1_pct_share"), - PrimaryKeyConstraint("simulation_run_id"), + PrimaryKeyConstraint("report_run_id", "simulation_run_id"), ) cliff_impacts = Table( @@ -150,7 +171,7 @@ def _uk_area_disclosure() -> list[SchemaItem]: simulation_metadata, *_simulation_run_key("cliff_impacts"), *_numbers("cliff_gap", "cliff_share"), - PrimaryKeyConstraint("simulation_run_id"), + PrimaryKeyConstraint("report_run_id", "simulation_run_id"), ) # --- Per report run --------------------------------------------------------- diff --git a/libs/policyengine-simulation-persistence/tests/test_economy_results.py b/libs/policyengine-simulation-persistence/tests/test_economy_results.py index 5c0c067ee..b8226614b 100644 --- a/libs/policyengine-simulation-persistence/tests/test_economy_results.py +++ b/libs/policyengine-simulation-persistence/tests/test_economy_results.py @@ -19,6 +19,8 @@ result_stores, ) from simulation_support import simulation_store +from sqlalchemy import text +from sqlalchemy.exc import ProgrammingError pytestmark = pytest.mark.postgres @@ -106,12 +108,21 @@ def test_each_uk_area_keeps_its_disclosure_tier_and_intervals( ] -def test_a_reused_baseline_keeps_its_figures_once( +def test_each_report_run_reads_its_own_figures_of_a_reused_baseline( migrated_database, public_api_rows ) -> None: + """A reused run's figures can differ by comparison, for example by SPM.""" first = load_sample("us_state_ca_2026") + child = first.poverty.poverty.child.model_copy(update={"baseline": 0.123456}) second = first.model_copy( - update={"budget": first.budget.model_copy(update={"budgetary_impact": 1.5})} + update={ + "budget": first.budget.model_copy(update={"budgetary_impact": 1.5}), + "poverty": first.poverty.model_copy( + update={ + "poverty": first.poverty.poverty.model_copy(update={"child": child}) + } + ), + } ) stores = result_stores(migrated_database) first_run = comparison_ready(stores, migrated_database, public_api_rows) @@ -132,6 +143,55 @@ def test_a_reused_baseline_keeps_its_figures_once( assert _read(migrated_database, second_run.id, labels) == second +def _executor_fallbacks(result): + """Return the shapes today's executor emits when a breakdown fails.""" + categories = list(result.intra_decile.all) + failed_intra = result.intra_decile.model_copy( + update={ + "deciles": {category: [] for category in categories}, + "all": dict.fromkeys(categories, 0.0), + } + ) + no_deciles = result.decile.model_copy(update={"average": {}, "relative": {}}) + return result.model_copy( + update={ + "intra_decile": failed_intra, + "intra_wealth_decile": failed_intra, + "wealth_decile": no_deciles, + } + ) + + +def test_the_executors_fallback_shapes_are_rebuilt_exactly( + migrated_database, public_api_rows +) -> None: + result = _executor_fallbacks(load_sample("uk_national_2026")) + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + _write(stores, report_run, RECORDER.validate(result.model_dump(mode="json"))) + + rebuilt = _read(migrated_database, report_run.id, labels_for(result)) + assert rebuilt.model_dump_json() == result.model_dump_json() + + +def test_an_empty_income_decile_map_is_rebuilt( + migrated_database, public_api_rows +) -> None: + sample = load_sample("us_budget_window_year_2030") + result = sample.model_copy( + update={ + "decile": sample.decile.model_copy(update={"average": {}, "relative": {}}) + } + ) + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + _write(stores, report_run, result) + + assert _read(migrated_database, report_run.id, labels_for(result)) == result + + def test_reading_needs_a_succeeded_comparison( migrated_database, public_api_rows ) -> None: @@ -174,8 +234,8 @@ def test_results_the_tables_cannot_hold_exactly_are_refused() -> None: reordered = result.decile.model_copy( update={"relative": dict(reversed(result.decile.relative.items()))} ) - no_shares = result.intra_decile.model_copy( - update={"deciles": {**result.intra_decile.deciles, "No change": []}} + other_categories = result.intra_decile.model_copy( + update={"all": dict(reversed(result.intra_decile.all.items()))} ) mixed = result.constituency_impact.model_copy( update={ @@ -185,11 +245,20 @@ def test_results_the_tables_cannot_hold_exactly_are_refused() -> None: ] } ) + repeated_constituency = result.constituency_impact.model_copy( + update={"root": [*result.constituency_impact.root[:2]] * 2} + ) + districts = load_sample("us_state_ca_2026").congressional_district_impact + repeated_district = districts.model_copy( + update={"districts": [*districts.districts[:1]] * 2} + ) for refused, message in ( (_with(result, decile=reordered), "decile"), - (_with(result, intra_decile=no_shares), "intra_decile"), + (_with(result, intra_decile=other_categories), "intra_decile"), (_with(result, constituency_impact=mixed), "constituency_impact"), + (_with(result, constituency_impact=repeated_constituency), "repeats"), + (_with(result, congressional_district_impact=repeated_district), "repeats"), ): with pytest.raises(ValueError, match=message): check_storable(refused) @@ -198,3 +267,25 @@ def test_results_the_tables_cannot_hold_exactly_are_refused() -> None: def test_the_recorder_refuses_output_that_is_not_a_result() -> None: with pytest.raises(ValidationError): RECORDER.validate({"budget": {"budgetary_impact": "much"}}) + + +@pytest.mark.parametrize("table_name", ["budget_impacts", "poverty_rates"]) +def test_the_runtime_role_cannot_change_stored_results( + migrated_database, public_api_rows, table_name +) -> None: + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + _write(stores, report_run, load_sample("us_budget_window_year_2030")) + engine = create_simulation_engine(migrated_database.runtime_url) + try: + for statement in ( + f"UPDATE simulation.{table_name} SET report_run_id = report_run_id", # noqa: S608 # fixed table names + f"DELETE FROM simulation.{table_name}", # noqa: S608 # fixed table names + ): + with ( + pytest.raises(ProgrammingError, match="permission denied"), + engine.begin() as connection, + ): + connection.execute(text(statement)) + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py index 40b498110..d116d72e0 100644 --- a/libs/policyengine-simulation-persistence/tests/test_schema.py +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -121,7 +121,23 @@ def test_importing_the_package_registers_every_table() -> None: "simulation.reports", "simulation.simulation_runs", "simulation.report_runs", - "simulation.poverty_rates", - "simulation.budget_impacts", - "simulation.local_authority_impacts", + *( + f"simulation.{name}" + for name in ( + "poverty_rates", + "inequality", + "cliff_impacts", + "budget_impacts", + "program_budget_impacts", + "decile_impacts", + "intra_decile_impacts", + "labor_supply_responses", + "labor_supply_decile_impacts", + "congressional_district_impacts", + "constituency_impacts", + "local_authority_impacts", + "household_outputs", + "output_files", + ) + ), } <= package_tables diff --git a/migrations/versions/0004_result_tables.py b/migrations/versions/0004_result_tables.py index feded21e0..f77a48071 100644 --- a/migrations/versions/0004_result_tables.py +++ b/migrations/versions/0004_result_tables.py @@ -7,8 +7,11 @@ comparison of a baseline and a reform (budget, programs, deciles, labour supply, geographies) belongs to the report run. Each report run also records the optional sections its result holds and its SPM provenance. Rows are -written in the transaction that marks a run succeeded; the default privileges -let the runtime role read and insert them. Nothing writes them yet. +written in the transaction that marks a run succeeded and never change: the +runtime role may read and insert them but not update or delete them. A +simulation's own figures are keyed by the report run that computed them too, +because a reused run's figures can differ between comparisons. Nothing writes +them yet. Revision ID: 0004 Revises: 0003 @@ -21,6 +24,22 @@ from alembic import op from sqlalchemy.dialects import postgresql +PER_SIMULATION_TABLES = ("poverty_rates", "inequality", "cliff_impacts") +RESULT_TABLES = ( + *PER_SIMULATION_TABLES, + "budget_impacts", + "program_budget_impacts", + "decile_impacts", + "intra_decile_impacts", + "labor_supply_responses", + "labor_supply_decile_impacts", + "congressional_district_impacts", + "constituency_impacts", + "local_authority_impacts", + "household_outputs", + "output_files", +) + revision = "0004" down_revision = "0003" branch_labels = None @@ -105,35 +124,54 @@ def upgrade() -> None: _create_output_files() op.create_table( "cliff_impacts", + sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("simulation_run_id", sa.Uuid(), nullable=False), sa.Column("cliff_gap", sa.Double(), nullable=False), sa.Column("cliff_share", sa.Double(), nullable=False), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_cliff_impacts_report_run", + ondelete="RESTRICT", + ), sa.ForeignKeyConstraint( ["simulation_run_id"], ["simulation.simulation_runs.id"], name="fk_cliff_impacts_simulation_run", ondelete="RESTRICT", ), - sa.PrimaryKeyConstraint("simulation_run_id", name=op.f("pk_cliff_impacts")), + sa.PrimaryKeyConstraint( + "report_run_id", "simulation_run_id", name=op.f("pk_cliff_impacts") + ), schema="simulation", ) op.create_table( "inequality", + sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("simulation_run_id", sa.Uuid(), nullable=False), sa.Column("gini", sa.Double(), nullable=False), sa.Column("top_10_pct_share", sa.Double(), nullable=False), sa.Column("top_1_pct_share", sa.Double(), nullable=False), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_inequality_report_run", + ondelete="RESTRICT", + ), sa.ForeignKeyConstraint( ["simulation_run_id"], ["simulation.simulation_runs.id"], name="fk_inequality_simulation_run", ondelete="RESTRICT", ), - sa.PrimaryKeyConstraint("simulation_run_id", name=op.f("pk_inequality")), + sa.PrimaryKeyConstraint( + "report_run_id", "simulation_run_id", name=op.f("pk_inequality") + ), schema="simulation", ) op.create_table( "poverty_rates", + sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("simulation_run_id", sa.Uuid(), nullable=False), sa.Column("measure", sa.Text(), nullable=False), sa.Column("group_dimension", sa.Text(), nullable=False), @@ -151,6 +189,12 @@ def upgrade() -> None: "measure IN ('poverty', 'deep_poverty')", name=op.f("ck_poverty_rates_measure"), ), + sa.ForeignKeyConstraint( + ["report_run_id"], + ["simulation.report_runs.id"], + name="fk_poverty_rates_report_run", + ondelete="RESTRICT", + ), sa.ForeignKeyConstraint( ["simulation_run_id"], ["simulation.simulation_runs.id"], @@ -158,6 +202,7 @@ def upgrade() -> None: ondelete="RESTRICT", ), sa.PrimaryKeyConstraint( + "report_run_id", "simulation_run_id", "measure", "group_dimension", @@ -432,6 +477,19 @@ def upgrade() -> None: ), schema="simulation", ) + for table in PER_SIMULATION_TABLES: + op.create_index( + op.f(f"ix_{table}_simulation_run_id"), + table, + ["simulation_run_id"], + schema="simulation", + ) + # Result rows are written once, with their run's success, and kept. + op.execute( + "REVOKE UPDATE ON " + + ", ".join(f"simulation.{table}" for table in RESULT_TABLES) + + " FROM simulation_runtime" + ) op.add_column( "report_runs", sa.Column("result_sections", postgresql.ARRAY(sa.Text()), nullable=True), From d9cf7a746734bb67fd48b4ad0b8c164ca45ede25 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:48:16 +0400 Subject: [PATCH 089/124] Store labour supply with a negative-income decile, and a run compared with itself Two cases the result tables refused or could not store, from the second review of 1.07: - policyengine.py puts households with negative net income in decile -1 and gives it labour supply average effects, while the relative effects cover positive deciles only. check_storable required all four labour supply decile maps to name the same deciles, so every real US and UK result with labour supply would have failed as invalid. Now each pair (average, relative) must name the same deciles in order, and labor_supply_decile_impacts holds one row per decile either pair names, with each pair's effects and order nullable for deciles the other pair leaves out (checks keep each pair complete). - A report run whose baseline and reform are the same simulation run wrote that run's figures twice under one key, so the write failed and the recording loop would have retried it until Modal dropped the output. poverty_rates, inequality and cliff_impacts now carry the side (baseline or reform) in their key, and each side is read back as written. Both cases have round-trip tests; the result-table README says how the rows are keyed. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 10 +- .../economy_results.py | 139 ++++++++++++------ .../result_tables.py | 46 ++++-- .../tests/test_economy_results.py | 75 +++++++++- migrations/versions/0004_result_tables.py | 54 +++++-- 5 files changed, 253 insertions(+), 71 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index da24545d1..d76c9d9ad 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -73,9 +73,13 @@ section of today's result model, `SingleYearMacroOutput`: | A simulation run (its own figures, as each report run's comparison computed them) | `poverty_rates`, `inequality`, `cliff_impacts` | | A report run (the comparison) | `budget_impacts`, `program_budget_impacts`, `decile_impacts` (income and wealth), `intra_decile_impacts` (income and wealth), `labor_supply_responses`, `labor_supply_decile_impacts`, `congressional_district_impacts`, `constituency_impacts`, `local_authority_impacts` | -A reused simulation run's own figures are keyed by the report run too, -because they can differ between comparisons (US poverty rates follow each -comparison's SPM selection). Lists and maps keep their order in a `position` +A reused simulation run's own figures are keyed by the report run and the +side (baseline or reform) too, because they can differ between comparisons +(US poverty rates follow each comparison's SPM selection) and one run can be +both sides of a report run. Labour supply decile rows keep the average and +the relative maps' orders separately, since the average maps may name a +decile the relative maps leave out (-1, for households with negative +income). Lists and maps keep their order in a `position` column. Result rows are written once and never changed: the runtime role may read and insert them but not update or delete them. The report run also records which optional sections its result holds (`result_sections`) and its diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index 5d900d7f6..9c0b791a9 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -12,7 +12,9 @@ ``check_storable``: the average and relative changes of each decile map must name the same deciles in the same order, the decile shares and averages of each intra-decile breakdown must name the same categories in the same order, -and the area lists must hold records of their own kind, each area once. +each pair of labour supply decile maps (average, relative) must name the same +deciles in order, and the area lists must hold records of their own kind, +each area once. """ from __future__ import annotations @@ -24,6 +26,7 @@ ConstituencyImpactRecord, DecileOutput, IntraDecileOutput, + LaborSupplyDecileOutput, LaborSupplyResponseOutput, LocalAuthorityImpactRecord, SingleYearMacroOutput, @@ -77,7 +80,8 @@ # Where each field of SingleYearMacroOutput is kept. "{key}" stands for a map # key and "[]" for a list item; per-simulation figures are kept on the -# baseline's or the reform's own simulation run. Labels come from the catalog +# baseline's or the reform's own simulation run, keyed by the report run and +# the side too. Labels come from the catalog # when the result is read. A test requires this to name every field. REFUSED_OTHER_KIND = "refused by check_storable: a record of the other kind" _GEOGRAPHY_FIELDS = ( @@ -251,21 +255,24 @@ def _check_unique(section: str, codes: list[str]) -> None: def _check_labor_supply(labor: LaborSupplyResponseOutput | None) -> None: + """Require each pair of decile maps to name the same deciles in order. + + The average pair may name deciles the relative pair leaves out, such as + decile -1 for households with negative income. + """ if labor is None: return metrics = labor.decile - orders = { - tuple(values) - for values in ( - metrics.average.income, - metrics.average.substitution, - metrics.relative.income, - metrics.relative.substitution, - ) - } - if len(orders) > 1: - msg = "labor_supply_response.decile: every map must name the same deciles" - raise ValueError(msg) + for statistic, metric in ( + ("average", metrics.average), + ("relative", metrics.relative), + ): + if list(metric.income) != list(metric.substitution): + msg = ( + f"labor_supply_response.decile.{statistic}: income and " + "substitution must name the same deciles in order" + ) + raise ValueError(msg) def check_storable(result: SingleYearMacroOutput) -> SingleYearMacroOutput: @@ -323,6 +330,7 @@ def _simulation_rows( { "report_run_id": report_run_id, "simulation_run_id": run_id, + "side": side, "measure": measure, "group_dimension": dimension, "group_value": group, @@ -336,6 +344,7 @@ def _simulation_rows( { "report_run_id": report_run_id, "simulation_run_id": run_id, + "side": side, **{name: getattr(value, side) for name, value in result.inequality}, } ], @@ -346,6 +355,7 @@ def _simulation_rows( { "report_run_id": report_run_id, "simulation_run_id": run_id, + "side": side, **cliff.model_dump(), } ] @@ -424,21 +434,39 @@ def _labor_supply_rows( "hours_substitution_effect": labor.hours.substitution_effect, } ], - labor_supply_decile_impacts: [ - { - "report_run_id": report_run_id, - "decile": key, - "position": position, - "average_income_effect": decile.average.income[key], - "average_substitution_effect": decile.average.substitution[key], - "relative_income_effect": decile.relative.income[key], - "relative_substitution_effect": decile.relative.substitution[key], - } - for position, key in enumerate(decile.average.income) - ], + labor_supply_decile_impacts: _labor_supply_decile_rows(report_run_id, decile), } +def _labor_supply_decile_rows( + report_run_id: UUID, decile: LaborSupplyDecileOutput +) -> list[dict[str, Any]]: + """Return one row per decile either pair of maps names, each with its order.""" + rows: dict[int, dict[str, Any]] = {} + for statistic, metric in ( + ("average", decile.average), + ("relative", decile.relative), + ): + for position, key in enumerate(metric.income): + row = rows.setdefault( + key, + { + "report_run_id": report_run_id, + "decile": key, + "average_position": None, + "relative_position": None, + "average_income_effect": None, + "average_substitution_effect": None, + "relative_income_effect": None, + "relative_substitution_effect": None, + }, + ) + row[f"{statistic}_position"] = position + row[f"{statistic}_income_effect"] = metric.income[key] + row[f"{statistic}_substitution_effect"] = metric.substitution[key] + return list(rows.values()) + + def _geography_rows( report_run_id: UUID, result: SingleYearMacroOutput ) -> dict[Table, list[dict[str, Any]]]: @@ -624,25 +652,37 @@ def _poverty(rows_by_side: dict[str, list[RowMapping]]) -> dict[str, Any]: def _figures( - connection: Connection, table: Table, report_run_id: UUID, run_id: UUID + connection: Connection, + table: Table, + report_run_id: UUID, + run_id: UUID, + side: str, ) -> list[RowMapping]: - """Return one simulation run's figures as one report run computed them.""" + """Return one side's own figures as one report run computed them.""" return list( connection.execute( select(table).where( table.c.report_run_id == report_run_id, table.c.simulation_run_id == run_id, + table.c.side == side, ) ).mappings() ) def _one_figure( - connection: Connection, table: Table, report_run_id: UUID, run_id: UUID + connection: Connection, + table: Table, + report_run_id: UUID, + run_id: UUID, + side: str, ) -> RowMapping: - rows = _figures(connection, table, report_run_id, run_id) + rows = _figures(connection, table, report_run_id, run_id, side) if len(rows) != 1: - msg = f"{table.name} has no row for run {run_id} in report run {report_run_id}" + msg = ( + f"{table.name} has no {side} row for run {run_id} " + f"in report run {report_run_id}" + ) raise LookupError(msg) return rows[0] @@ -654,14 +694,15 @@ def _simulation_sections( sections: set[str], ) -> dict[str, Any]: by_side = { - side: _figures(connection, poverty_rates, report_run_id, run_id) + side: _figures(connection, poverty_rates, report_run_id, run_id, side) for side, run_id in zip(SIDES, run_ids, strict=True) } payload = _poverty(by_side) if "poverty_by_race" not in sections: payload["poverty_by_race"] = None inequality_rows = [ - _one_figure(connection, inequality, report_run_id, run_id) for run_id in run_ids + _one_figure(connection, inequality, report_run_id, run_id, side) + for side, run_id in zip(SIDES, run_ids, strict=True) ] payload["inequality"] = { name: { @@ -672,8 +713,8 @@ def _simulation_sections( payload["cliff_impact"] = None if "cliff_impact" in sections: cliff_rows = [ - _one_figure(connection, cliff_impacts, report_run_id, run_id) - for run_id in run_ids + _one_figure(connection, cliff_impacts, report_run_id, run_id, side) + for side, run_id in zip(SIDES, run_ids, strict=True) ] payload["cliff_impact"] = { side: {name: row[name] for name in ("cliff_gap", "cliff_share")} @@ -713,14 +754,24 @@ def _intra_deciles(rows: list[RowMapping], measure: str) -> dict[str, Any]: } +def _labor_supply_decile_maps( + rows: list[RowMapping], statistic: str +) -> dict[str, dict[int, float]]: + """Rebuild one pair of labour supply decile maps in its own order.""" + named = sorted( + (row for row in rows if row[f"{statistic}_position"] is not None), + key=lambda row: row[f"{statistic}_position"], + ) + return { + effect: {row["decile"]: row[f"{statistic}_{effect}_effect"] for row in named} + for effect in ("income", "substitution") + } + + def _labor_supply(connection: Connection, report_run_id: UUID) -> dict[str, Any]: response = _one(connection, labor_supply_responses, "report_run_id", report_run_id) deciles = _rows( - connection, - labor_supply_decile_impacts, - "report_run_id", - report_run_id, - order=("position",), + connection, labor_supply_decile_impacts, "report_run_id", report_run_id ) return { "substitution_lsr": response["substitution_lsr"], @@ -732,13 +783,7 @@ def _labor_supply(connection: Connection, report_run_id: UUID) -> dict[str, Any] "total_change": response["total_change"], "revenue_change": response["revenue_change"], "decile": { - statistic: { - effect: { - row["decile"]: row[f"{statistic}_{effect}_effect"] - for row in deciles - } - for effect in ("income", "substitution") - } + statistic: _labor_supply_decile_maps(deciles, statistic) for statistic in ("average", "relative") }, "hours": { diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py index 955c72cd0..d960d5f02 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -74,6 +74,10 @@ def _simulation_run_key(table_name: str) -> list[SchemaItem]: return [ Column("report_run_id", Uuid(as_uuid=True), nullable=False), Column("simulation_run_id", Uuid(as_uuid=True), nullable=False), + # Which side of the comparison the figures describe; it tells them + # apart when one simulation run is both the baseline and the reform. + Column("side", Text, nullable=False), + CheckConstraint("side IN ('baseline', 'reform')", name="side"), ForeignKeyConstraint( ["report_run_id"], [report_runs.c.id], @@ -143,6 +147,7 @@ def _uk_area_disclosure() -> list[SchemaItem]: PrimaryKeyConstraint( "report_run_id", "simulation_run_id", + "side", "measure", "group_dimension", "group_value", @@ -163,7 +168,7 @@ def _uk_area_disclosure() -> list[SchemaItem]: simulation_metadata, *_simulation_run_key("inequality"), *_numbers("gini", "top_10_pct_share", "top_1_pct_share"), - PrimaryKeyConstraint("report_run_id", "simulation_run_id"), + PrimaryKeyConstraint("report_run_id", "simulation_run_id", "side"), ) cliff_impacts = Table( @@ -171,7 +176,7 @@ def _uk_area_disclosure() -> list[SchemaItem]: simulation_metadata, *_simulation_run_key("cliff_impacts"), *_numbers("cliff_gap", "cliff_share"), - PrimaryKeyConstraint("report_run_id", "simulation_run_id"), + PrimaryKeyConstraint("report_run_id", "simulation_run_id", "side"), ) # --- Per report run --------------------------------------------------------- @@ -259,20 +264,41 @@ def _uk_area_disclosure() -> list[SchemaItem]: PrimaryKeyConstraint("report_run_id"), ) +# One row per decile any of the four maps names. The average maps may name a +# decile the relative maps leave out: policyengine.py puts households with +# negative income in decile -1 and gives relative effects for positive +# deciles only. Each pair keeps its own order. labor_supply_decile_impacts = Table( "labor_supply_decile_impacts", simulation_metadata, *_report_run_key("labor_supply_decile_impacts"), Column("decile", Integer, nullable=False), - *_position(), - *_numbers( - "average_income_effect", - "average_substitution_effect", - "relative_income_effect", - "relative_substitution_effect", - ), + Column("average_position", Integer, nullable=True), + Column("relative_position", Integer, nullable=True), + Column("average_income_effect", Double, nullable=True), + Column("average_substitution_effect", Double, nullable=True), + Column("relative_income_effect", Double, nullable=True), + Column("relative_substitution_effect", Double, nullable=True), PrimaryKeyConstraint("report_run_id", "decile"), - UniqueConstraint("report_run_id", "position"), + UniqueConstraint("report_run_id", "average_position"), + UniqueConstraint("report_run_id", "relative_position"), + CheckConstraint( + "(average_position IS NULL) = (average_income_effect IS NULL) " + "AND (average_position IS NULL) = (average_substitution_effect IS NULL)", + name="average", + ), + CheckConstraint( + "(relative_position IS NULL) = (relative_income_effect IS NULL) " + "AND (relative_position IS NULL) = (relative_substitution_effect IS NULL)", + name="relative", + ), + CheckConstraint( + "average_position IS NOT NULL OR relative_position IS NOT NULL", + name="in_a_map", + ), + CheckConstraint( + "average_position >= 0 AND relative_position >= 0", name="positions" + ), ) congressional_district_impacts = Table( diff --git a/libs/policyengine-simulation-persistence/tests/test_economy_results.py b/libs/policyengine-simulation-persistence/tests/test_economy_results.py index b8226614b..625af84e3 100644 --- a/libs/policyengine-simulation-persistence/tests/test_economy_results.py +++ b/libs/policyengine-simulation-persistence/tests/test_economy_results.py @@ -6,11 +6,14 @@ from policyengine_simulation_persistence import ( EconomyComparisonRecorder, EconomyResultLabels, + NewReportRun, + RunTrigger, check_storable, create_simulation_engine, read_economy_comparison, ) from pydantic import ValidationError +from report_support import economy_report, report_store from result_support import ( SAMPLE_NAMES, comparison_ready, @@ -18,7 +21,8 @@ load_sample, result_stores, ) -from simulation_support import simulation_store +from run_support import economy_run +from simulation_support import economy_simulation, simulation_store from sqlalchemy import text from sqlalchemy.exc import ProgrammingError @@ -192,6 +196,75 @@ def test_an_empty_income_decile_map_is_rebuilt( assert _read(migrated_database, report_run.id, labels_for(result)) == result +def _with_negative_income_decile(result): + """Add the -1 decile policyengine.py gives households with negative income. + + It appears in the average labour supply maps only; the relative maps keep + positive deciles. + """ + labor = result.labor_supply_response + average = labor.decile.average + widened = average.model_copy( + update={ + "income": {-1: -12.5, **average.income}, + "substitution": {-1: 3.25, **average.substitution}, + } + ) + decile = labor.decile.model_copy(update={"average": widened}) + return result.model_copy( + update={"labor_supply_response": labor.model_copy(update={"decile": decile})} + ) + + +@pytest.mark.parametrize("sample_name", ["us_national_2026", "uk_national_2026"]) +def test_labour_supply_with_a_negative_income_decile_is_rebuilt( + migrated_database, public_api_rows, sample_name +) -> None: + result = _with_negative_income_decile(load_sample(sample_name)) + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + _write(stores, report_run, RECORDER.validate(result.model_dump(mode="json"))) + + rebuilt = _read(migrated_database, report_run.id, labels_for(result)) + assert rebuilt.model_dump_json() == result.model_dump_json() + + +def test_a_report_run_comparing_a_run_with_itself_is_stored( + migrated_database, public_api_rows +) -> None: + result = load_sample("us_state_ca_2026") + stores = result_stores(migrated_database) + simulation = simulation_store(migrated_database).create( + economy_simulation(public_api_rows, policy_id=None) + ) + report = report_store(migrated_database).create( + economy_report(simulation, simulation) + ) + run = stores.runs.start_simulation_run(economy_run(simulation, public_api_rows)).run + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id="fc-itself", first_check_at=stores.clock() + ) + stores.transitions.complete_simulation_call("fc-itself", lambda *_: None) + report_run = stores.runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=run.id, + reform_simulation_run_id=run.id, + trigger=RunTrigger.INITIAL, + ) + ) + stores.transitions.claim_comparison_dispatch(report_run.id) + stores.transitions.record_comparison_dispatched( + report_run.id, modal_call_id="fc-comparison", first_check_at=stores.clock() + ) + + _write(stores, report_run, result) + + assert _read(migrated_database, report_run.id, labels_for(result)) == result + + def test_reading_needs_a_succeeded_comparison( migrated_database, public_api_rows ) -> None: diff --git a/migrations/versions/0004_result_tables.py b/migrations/versions/0004_result_tables.py index f77a48071..515877d02 100644 --- a/migrations/versions/0004_result_tables.py +++ b/migrations/versions/0004_result_tables.py @@ -126,8 +126,12 @@ def upgrade() -> None: "cliff_impacts", sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("side", sa.Text(), nullable=False), sa.Column("cliff_gap", sa.Double(), nullable=False), sa.Column("cliff_share", sa.Double(), nullable=False), + sa.CheckConstraint( + "side IN ('baseline', 'reform')", name=op.f("ck_cliff_impacts_side") + ), sa.ForeignKeyConstraint( ["report_run_id"], ["simulation.report_runs.id"], @@ -141,7 +145,7 @@ def upgrade() -> None: ondelete="RESTRICT", ), sa.PrimaryKeyConstraint( - "report_run_id", "simulation_run_id", name=op.f("pk_cliff_impacts") + "report_run_id", "simulation_run_id", "side", name=op.f("pk_cliff_impacts") ), schema="simulation", ) @@ -149,9 +153,13 @@ def upgrade() -> None: "inequality", sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("side", sa.Text(), nullable=False), sa.Column("gini", sa.Double(), nullable=False), sa.Column("top_10_pct_share", sa.Double(), nullable=False), sa.Column("top_1_pct_share", sa.Double(), nullable=False), + sa.CheckConstraint( + "side IN ('baseline', 'reform')", name=op.f("ck_inequality_side") + ), sa.ForeignKeyConstraint( ["report_run_id"], ["simulation.report_runs.id"], @@ -165,7 +173,7 @@ def upgrade() -> None: ondelete="RESTRICT", ), sa.PrimaryKeyConstraint( - "report_run_id", "simulation_run_id", name=op.f("pk_inequality") + "report_run_id", "simulation_run_id", "side", name=op.f("pk_inequality") ), schema="simulation", ) @@ -173,6 +181,7 @@ def upgrade() -> None: "poverty_rates", sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("simulation_run_id", sa.Uuid(), nullable=False), + sa.Column("side", sa.Text(), nullable=False), sa.Column("measure", sa.Text(), nullable=False), sa.Column("group_dimension", sa.Text(), nullable=False), sa.Column("group_value", sa.Text(), nullable=False), @@ -189,6 +198,9 @@ def upgrade() -> None: "measure IN ('poverty', 'deep_poverty')", name=op.f("ck_poverty_rates_measure"), ), + sa.CheckConstraint( + "side IN ('baseline', 'reform')", name=op.f("ck_poverty_rates_side") + ), sa.ForeignKeyConstraint( ["report_run_id"], ["simulation.report_runs.id"], @@ -204,6 +216,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint( "report_run_id", "simulation_run_id", + "side", "measure", "group_dimension", "group_value", @@ -363,13 +376,29 @@ def upgrade() -> None: "labor_supply_decile_impacts", sa.Column("report_run_id", sa.Uuid(), nullable=False), sa.Column("decile", sa.Integer(), nullable=False), - sa.Column("position", sa.Integer(), nullable=False), - sa.Column("average_income_effect", sa.Double(), nullable=False), - sa.Column("average_substitution_effect", sa.Double(), nullable=False), - sa.Column("relative_income_effect", sa.Double(), nullable=False), - sa.Column("relative_substitution_effect", sa.Double(), nullable=False), + sa.Column("average_position", sa.Integer(), nullable=True), + sa.Column("relative_position", sa.Integer(), nullable=True), + sa.Column("average_income_effect", sa.Double(), nullable=True), + sa.Column("average_substitution_effect", sa.Double(), nullable=True), + sa.Column("relative_income_effect", sa.Double(), nullable=True), + sa.Column("relative_substitution_effect", sa.Double(), nullable=True), + sa.CheckConstraint( + "(average_position IS NULL) = (average_income_effect IS NULL) " + "AND (average_position IS NULL) = (average_substitution_effect IS NULL)", + name=op.f("ck_labor_supply_decile_impacts_average"), + ), sa.CheckConstraint( - "position >= 0", name=op.f("ck_labor_supply_decile_impacts_position") + "(relative_position IS NULL) = (relative_income_effect IS NULL) " + "AND (relative_position IS NULL) = (relative_substitution_effect IS NULL)", + name=op.f("ck_labor_supply_decile_impacts_relative"), + ), + sa.CheckConstraint( + "average_position IS NOT NULL OR relative_position IS NOT NULL", + name=op.f("ck_labor_supply_decile_impacts_in_a_map"), + ), + sa.CheckConstraint( + "average_position >= 0 AND relative_position >= 0", + name=op.f("ck_labor_supply_decile_impacts_positions"), ), sa.ForeignKeyConstraint( ["report_run_id"], @@ -382,8 +411,13 @@ def upgrade() -> None: ), sa.UniqueConstraint( "report_run_id", - "position", - name=op.f("uq_labor_supply_decile_impacts_report_run_id_position"), + "average_position", + name=op.f("uq_labor_supply_decile_impacts_report_run_id_average_position"), + ), + sa.UniqueConstraint( + "report_run_id", + "relative_position", + name=op.f("uq_labor_supply_decile_impacts_report_run_id_relative_position"), ), schema="simulation", ) From 29d95b20b00861b5cfde9a91613b0857018af3a8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:21:28 +0400 Subject: [PATCH 090/124] Store the model and data versions a result reports The reader took a result's model and data versions from labels for the run's catalog version. Since main's runtime reports a US state or district run's data_version as the revision of the dataset it loaded (the ACS-local dataset), not the release's default data version, such a result would have been read back with a different data_version from the one written. report_runs gains result_model_version and result_data_version, written with the comparison's rows and read back as written; EconomyResultLabels now holds only constituency and local-authority labels. A test writes a US state result whose data_version names its dataset's revision and reads it back with the catalog's labels. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 20 ++++++++------- .../economy_results.py | 24 +++++++++--------- .../result_tables.py | 6 ++--- .../runs.py | 6 ++++- .../tests/result_support.py | 2 -- .../tests/test_economy_results.py | 25 ++++++++++++++++--- .../tests/test_result_completeness.py | 2 -- migrations/versions/0004_result_tables.py | 8 ++++++ 8 files changed, 61 insertions(+), 32 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index d76c9d9ad..a59dc10d4 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -82,17 +82,19 @@ decile the relative maps leave out (-1, for households with negative income). Lists and maps keep their order in a `position` column. Result rows are written once and never changed: the runtime role may read and insert them but not update or delete them. The report run also -records which optional sections its result holds (`result_sections`) and its -SPM provenance. `write_economy_comparison` checks the result can be held -exactly (`check_storable`) and writes its rows in the transaction that marks -the comparison step succeeded (`RunTransitions.complete_comparison`); +records which optional sections its result holds (`result_sections`), its SPM +provenance, and the model and data versions the result reports (a US state +run reports the revision of the dataset it loaded, which is not the release's +default data version). `write_economy_comparison` checks the result can be +held exactly (`check_storable`) and writes its rows in the transaction that +marks the comparison step succeeded (`RunTransitions.complete_comparison`); `EconomyComparisonRecorder` pairs that writer with validation against the result model. `read_economy_comparison` rebuilds the result exactly from the -rows and from labels the region catalog supplies (`EconomyResultLabels`): the -model and data versions of the run's catalog version, and constituency and -local-authority names and map positions by code. Each UK area's disclosure -tier and the 90% interval bounds of its income changes are stored with its -figures (null for a result computed before disclosure control). +rows and from labels the region catalog supplies (`EconomyResultLabels`): +constituency and local-authority names and map positions by code. Each UK +area's disclosure tier and the 90% interval bounds of its income changes are +stored with its figures (null for a result computed before disclosure +control). A computed household is one JSON value (`household_outputs`, PostgreSQL `json`, so its text, key order included, is kept as written) on its diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index 9c0b791a9..92a4d6522 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -3,10 +3,11 @@ ``write_economy_comparison`` runs in the transaction that marks a report run's comparison step succeeded (``RunTransitions.complete_comparison``), so a run never succeeds without its rows. ``read_economy_comparison`` rebuilds today's -result format, ``SingleYearMacroOutput``, from the rows and from labels the -region catalog supplies: the model and data versions of the run's catalog -version, and the names and map positions of constituencies and local -authorities by code. +result format, ``SingleYearMacroOutput``, from the rows and from the labels +the region catalog supplies: the names and map positions of constituencies and +local authorities by code. The model and data versions are stored as the +result reports them, since a run on a non-default dataset (a US state run on +the ACS-local dataset) reports that dataset's revision. A result the tables cannot hold exactly is refused before any write by ``check_storable``: the average and relative changes of each decile map must @@ -121,8 +122,8 @@ def _geography_storage( RESULT_FIELD_STORAGE: dict[str, str] = { "spm_provenance": "report_runs.spm_provenance", - "model_version": "catalog version: model version", - "data_version": "catalog version: data version", + "model_version": "report_runs.result_model_version", + "data_version": "report_runs.result_data_version", **{ f"budget.{name}": f"budget_impacts.{name}" for name in ( @@ -221,12 +222,9 @@ class GeographyLabel: class EconomyResultLabels: """What a stored result takes from the region catalog when it is read. - The model and data versions belong to the run's catalog version; the - geography labels are looked up by code. + Constituency and local-authority names and map positions, by code. """ - model_version: str - data_version: str constituencies: Mapping[str, GeographyLabel] = field(default_factory=dict) local_authorities: Mapping[str, GeographyLabel] = field(default_factory=dict) @@ -580,6 +578,8 @@ def write_economy_comparison( if result.spm_provenance is None else result.spm_provenance.model_dump(mode="json") ), + result_model_version=result.model_version, + result_data_version=result.data_version, ) ) @@ -956,8 +956,8 @@ def read_economy_comparison( return SingleYearMacroOutput.model_validate( { "spm_provenance": report_run_row["spm_provenance"], - "model_version": labels.model_version, - "data_version": labels.data_version, + "model_version": report_run_row["result_model_version"], + "data_version": report_run_row["result_data_version"], **_comparison_sections(connection, report_run_id, sections, labels), **_simulation_sections(connection, report_run_id, run_ids, sections), } diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py index d960d5f02..2aaf8b62e 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -12,9 +12,9 @@ Constituency and local-authority names and map positions are not stored: they come from the region catalog by code. Each area's disclosure tier and 90% interval bounds are stored with its figures, null for a result computed before -disclosure control. The model and data versions are pinned -by the run's catalog version. The SPM provenance and the list of optional -sections a result holds are columns on the report run. +disclosure control. The model and data versions the result reports, its SPM +provenance and the list of optional sections it holds are columns on the +report run. """ from __future__ import annotations diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py index 59e6eb926..39c5e85f0 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -282,9 +282,13 @@ def _execution_columns(prefix: str, *, required: bool) -> list[Column[Any]]: Column("created_at", DateTime(timezone=True), nullable=False), Column("updated_at", DateTime(timezone=True), nullable=False), # Set with an economy comparison's result rows (item 1.07): the optional - # sections the result holds, and its SPM provenance, if any. + # sections the result holds, its SPM provenance, if any, and the model and + # data versions it reports (a US state run reports the revision of the + # dataset it loaded, not the release's default data version). Column("result_sections", ARRAY(Text), nullable=True), Column("spm_provenance", JSONB(none_as_null=True), nullable=True), + Column("result_model_version", Text, nullable=True), + Column("result_data_version", Text, nullable=True), CheckConstraint(f"year BETWEEN {FIRST_YEAR} AND {LAST_YEAR}", name="year"), CheckConstraint(f"trigger IN ({_values(RunTrigger)})", name="trigger"), CheckConstraint( diff --git a/libs/policyengine-simulation-persistence/tests/result_support.py b/libs/policyengine-simulation-persistence/tests/result_support.py index 1fec3bed9..49eba726e 100644 --- a/libs/policyengine-simulation-persistence/tests/result_support.py +++ b/libs/policyengine-simulation-persistence/tests/result_support.py @@ -53,8 +53,6 @@ def labels(records, code_field: str, name_field: str): } return EconomyResultLabels( - model_version=result.model_version, - data_version=result.data_version, constituencies=labels( result.constituency_impact, "constituency_code", "constituency_name" ), diff --git a/libs/policyengine-simulation-persistence/tests/test_economy_results.py b/libs/policyengine-simulation-persistence/tests/test_economy_results.py index 625af84e3..700042ab6 100644 --- a/libs/policyengine-simulation-persistence/tests/test_economy_results.py +++ b/libs/policyengine-simulation-persistence/tests/test_economy_results.py @@ -75,6 +75,27 @@ def test_a_stored_result_is_rebuilt_exactly( assert rebuilt == result +def test_a_result_keeps_the_versions_it_reports( + migrated_database, public_api_rows +) -> None: + # A US state run reports the revision of the dataset it loaded (the + # ACS-local dataset), not the release's default data version, which is + # what the region catalog gives for the run's catalog version. + catalog_result = load_sample("us_state_ca_2026") + result = catalog_result.model_copy( + update={"data_version": "populace-us-acs-local-sample-revision"} + ) + stores = result_stores(migrated_database) + report_run = comparison_ready(stores, migrated_database, public_api_rows) + + _write(stores, report_run, RECORDER.validate(result.model_dump(mode="json"))) + + rebuilt = _read(migrated_database, report_run.id, labels_for(catalog_result)) + assert rebuilt.data_version == result.data_version + assert rebuilt.model_version == result.model_version + assert rebuilt == result + + UK_AREA_DISCLOSURE_FIELDS = { "disclosure_tier", "average_household_income_change_lower", @@ -275,7 +296,7 @@ def test_reading_needs_a_succeeded_comparison( _read( migrated_database, report_run.id, - EconomyResultLabels(model_version="1", data_version="1"), + EconomyResultLabels(), ) @@ -288,8 +309,6 @@ def test_reading_needs_a_label_for_every_geography( _write(stores, report_run, result) labels = labels_for(result) missing = EconomyResultLabels( - model_version=labels.model_version, - data_version=labels.data_version, constituencies=dict(list(labels.constituencies.items())[1:]), local_authorities=labels.local_authorities, ) diff --git a/libs/policyengine-simulation-persistence/tests/test_result_completeness.py b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py index 42828c3c1..937d2b1f1 100644 --- a/libs/policyengine-simulation-persistence/tests/test_result_completeness.py +++ b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py @@ -17,8 +17,6 @@ # Stored whole, as one JSON column. WHOLE_VALUES = (SPMComparisonProvenance,) CATALOG_SOURCES = { - "catalog version: model version", - "catalog version: data version", "region catalog: name", "region catalog: map x", "region catalog: map y", diff --git a/migrations/versions/0004_result_tables.py b/migrations/versions/0004_result_tables.py index 515877d02..032836eb3 100644 --- a/migrations/versions/0004_result_tables.py +++ b/migrations/versions/0004_result_tables.py @@ -536,6 +536,12 @@ def upgrade() -> None: ), schema="simulation", ) + for column in ("result_model_version", "result_data_version"): + op.add_column( + "report_runs", + sa.Column(column, sa.Text(), nullable=True), + schema="simulation", + ) op.create_check_constraint( op.f("ck_report_runs_spm_provenance"), "report_runs", @@ -552,6 +558,8 @@ def downgrade() -> None: schema="simulation", type_="check", ) + op.drop_column("report_runs", "result_data_version", schema="simulation") + op.drop_column("report_runs", "result_model_version", schema="simulation") op.drop_column("report_runs", "spm_provenance", schema="simulation") op.drop_column("report_runs", "result_sections", schema="simulation") op.drop_table("program_budget_impacts", schema="simulation") From cafd9d77670a477578fe886a5230f2657d516034 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:22:04 +0400 Subject: [PATCH 091/124] Declare pydantic for the household writer and describe the labour supply check From the third review of 1.07: the household writer imports pydantic, which the persistence library received only through the contract library, so it is now a declared dependency (locks refreshed). The economy_results module described the labour supply check as it was before decile -1 was accepted; it now says that each pair's income and substitution maps must name the same deciles in order, and that the average pair may name deciles the relative pair leaves out. Co-Authored-By: Claude Opus 5.5 --- libs/policyengine-simulation-persistence/pyproject.toml | 2 ++ .../economy_results.py | 9 +++++---- libs/policyengine-simulation-persistence/uv.lock | 2 ++ projects/policyengine-simulation-entry/uv.lock | 2 ++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml index cf04f772f..ffd61c5ea 100644 --- a/libs/policyengine-simulation-persistence/pyproject.toml +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ # The result models the result tables store and rebuild. "policyengine-simulation-contract", "psycopg[binary]>=3.2,<4", + # The household writer validates with pydantic directly. + "pydantic>=2,<3", "sqlalchemy>=2,<3", ] diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index 92a4d6522..d41e576ed 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -13,9 +13,10 @@ ``check_storable``: the average and relative changes of each decile map must name the same deciles in the same order, the decile shares and averages of each intra-decile breakdown must name the same categories in the same order, -each pair of labour supply decile maps (average, relative) must name the same -deciles in order, and the area lists must hold records of their own kind, -each area once. +within each labour supply decile map pair (average, relative) the income and +substitution effects must name the same deciles in order (the average pair may +name deciles the relative pair leaves out, such as decile -1), and the area +lists must hold records of their own kind, each area once. """ from __future__ import annotations @@ -253,7 +254,7 @@ def _check_unique(section: str, codes: list[str]) -> None: def _check_labor_supply(labor: LaborSupplyResponseOutput | None) -> None: - """Require each pair of decile maps to name the same deciles in order. + """Require each pair's income and substitution maps to name one decile order. The average pair may name deciles the relative pair leaves out, such as decile -1 for households with negative income. diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock index f19c0f00e..b78f0fa75 100644 --- a/libs/policyengine-simulation-persistence/uv.lock +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -650,6 +650,7 @@ source = { editable = "." } dependencies = [ { name = "policyengine-simulation-contract" }, { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, { name = "sqlalchemy" }, ] @@ -673,6 +674,7 @@ requires-dist = [ { name = "alembic", marker = "extra == 'test'", specifier = ">=1.17.1,<2" }, { name = "policyengine-simulation-contract", editable = "../policyengine-simulation-contract" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pydantic", specifier = ">=2,<3" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index 1dd314d3f..594f94e69 100644 --- a/projects/policyengine-simulation-entry/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -972,6 +972,7 @@ source = { editable = "../../libs/policyengine-simulation-persistence" } dependencies = [ { name = "policyengine-simulation-contract" }, { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, { name = "sqlalchemy" }, ] @@ -981,6 +982,7 @@ requires-dist = [ { name = "alembic", marker = "extra == 'test'", specifier = ">=1.17.1,<2" }, { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pydantic", specifier = ">=2,<3" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, From 8cfdc57823fdc5549054f7e2db1cd281e8abe92a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:56:06 +0400 Subject: [PATCH 092/124] Describe the result tables' privileges and write the disclosure tiers out From the fourth review of 1.07 (no bugs found): the persistence README's roles table and revision 0004's docstring did not say that 0004 revokes UPDATE on the result tables or adds the two version columns. And the UK area tables' tier check was built from the contract when imported, while revision 0004 writes the two tiers out, so a tier added to the contract would have changed the declared table without a test failing; the tiers are now written out in the table definitions too, and a test requires them to match the contract's. Co-Authored-By: Claude Opus 5.5 --- libs/policyengine-simulation-persistence/README.md | 5 +++-- .../result_tables.py | 7 ++++--- .../tests/test_result_completeness.py | 11 ++++++++++- migrations/versions/0004_result_tables.py | 3 ++- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index a59dc10d4..8b6d26e54 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -141,11 +141,12 @@ These rules are registered in `rules/registry.toml` and checked by | Role | Used by | Privileges | | --- | --- | --- | | `simulation_migrator` | The deploy workflow's migration job, through `SIMULATION_MIGRATION_DATABASE_URL` | Creates the `simulation` schema if it is missing, and owns every object in it; `REFERENCES` on the Public API tables its foreign keys point to | -| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create, except `UPDATE` on the definition tables (`simulations`, `reports`); never `DELETE`, and nothing on the Public API tables | +| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create, except `UPDATE` on the definition tables (`simulations`, `reports`) and the result tables; never `DELETE`, and nothing on the Public API tables | The initial revision grants the runtime role its privileges, including default privileges on tables the migrator creates later; revision 0002 revokes -`UPDATE` on the definition tables, which never change. Both roles must exist +`UPDATE` on the definition tables, which never change, and revision 0004 on +the result tables, whose rows are written once. Both roles must exist before the first migration runs; an operator creates them. Before revision 0002 is deployed, the owner of the Public API's tables must also grant the migrator `REFERENCES` on `policies`, `households` and `dynamics`, and diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py index 2aaf8b62e..d80d8ea3c 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -19,9 +19,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, get_args +from typing import TYPE_CHECKING -from policyengine_simulation_contract.macro_output import UKGeographyDisclosureTier from sqlalchemy import ( CheckConstraint, Column, @@ -50,7 +49,9 @@ "race": ("white", "black", "hispanic", "other"), } DECILE_MEASURES = ("income", "wealth") -UK_AREA_DISCLOSURE_TIERS: tuple[str, ...] = get_args(UKGeographyDisclosureTier) +# The contract's UKGeographyDisclosureTier values, written out so that a new +# tier fails a test rather than silently changing the declared table. +UK_AREA_DISCLOSURE_TIERS = ("reported", "approximate") UK_AREA_DISCLOSURE_COLUMNS = ( "disclosure_tier", "average_household_income_change_lower", diff --git a/libs/policyengine-simulation-persistence/tests/test_result_completeness.py b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py index 937d2b1f1..89eadeea3 100644 --- a/libs/policyengine-simulation-persistence/tests/test_result_completeness.py +++ b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py @@ -5,13 +5,17 @@ import types import typing -from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.macro_output import ( + SingleYearMacroOutput, + UKGeographyDisclosureTier, +) from policyengine_simulation_contract.spm import SPMComparisonProvenance from policyengine_simulation_persistence import ( RESULT_FIELD_STORAGE, simulation_metadata, ) from policyengine_simulation_persistence.economy_results import REFUSED_OTHER_KIND +from policyengine_simulation_persistence.result_tables import UK_AREA_DISCLOSURE_TIERS from pydantic import BaseModel, RootModel # Stored whole, as one JSON column. @@ -72,3 +76,8 @@ def test_every_storage_location_exists() -> None: table_name, column_name = location.split(".") table = simulation_metadata.tables[f"simulation.{table_name}"] assert column_name in table.c, path + + +def test_the_tables_accept_every_disclosure_tier_the_contract_names() -> None: + """A new tier needs a migration of the UK area tables' check.""" + assert typing.get_args(UKGeographyDisclosureTier) == UK_AREA_DISCLOSURE_TIERS diff --git a/migrations/versions/0004_result_tables.py b/migrations/versions/0004_result_tables.py index 032836eb3..12665d157 100644 --- a/migrations/versions/0004_result_tables.py +++ b/migrations/versions/0004_result_tables.py @@ -6,7 +6,8 @@ (poverty rates, inequality, cliff exposure) belong to its simulation run; the comparison of a baseline and a reform (budget, programs, deciles, labour supply, geographies) belongs to the report run. Each report run also records -the optional sections its result holds and its SPM provenance. Rows are +the optional sections its result holds, its SPM provenance, and the model and +data versions the result reports. Rows are written in the transaction that marks a run succeeded and never change: the runtime role may read and insert them but not update or delete them. A simulation's own figures are keyed by the report run that computed them too, From 9f163a26088731c5016b568f5c3f63ab486f8dbb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:35:22 +0400 Subject: [PATCH 093/124] Describe the comparison recorder without the removed loop EconomyComparisonRecorder's docstring named the recording loop, which 1.06 replaced with the reporting route and the safety check; it now says it validates and writes in the form RunRecorder expects. Co-Authored-By: Claude Opus 5.5 --- .../src/policyengine_simulation_persistence/economy_results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index d41e576ed..8a46ec363 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -587,7 +587,7 @@ def write_economy_comparison( @dataclass(frozen=True) class EconomyComparisonRecorder: - """Validate a comparison's output and write it, for the recording loop.""" + """Validate a comparison's output and write it, as RunRecorder expects.""" def validate(self, output: object) -> SingleYearMacroOutput: """Return the output as a result the tables can hold; else raise ValueError.""" From 2715b5173f92b3b3b95ecaf0ff95ca4d48b72fd6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:37:35 +0400 Subject: [PATCH 094/124] Split the economy result module into its storage map, writer and reader main has since added a rule (sim.quality.python-file-length) that a module has at most 800 lines, and economy_results had 965. It is split by responsibility, without changing behaviour: - economy_result_storage: OPTIONAL_SECTIONS, SIDES, RESULT_FIELD_STORAGE and the catalog labels (GeographyLabel, EconomyResultLabels); - economy_results: the checks, the writer and EconomyComparisonRecorder; - economy_result_reader: read_economy_comparison and its helpers. economy_results re-exports every public name, so imports keep working; the same 183 persistence tests pass. Co-Authored-By: Claude Opus 5.5 --- .../economy_result_reader.py | 413 +++++++++++++ .../economy_result_storage.py | 188 ++++++ .../economy_results.py | 561 +----------------- 3 files changed, 628 insertions(+), 534 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_reader.py create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_storage.py diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_reader.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_reader.py new file mode 100644 index 000000000..de6b16ec7 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_reader.py @@ -0,0 +1,413 @@ +"""Rebuild an economy comparison's result from its rows. + +``read_economy_comparison`` rebuilds today's result format, +``SingleYearMacroOutput``, from the rows ``economy_results`` wrote and from +the labels the region catalog supplies (``EconomyResultLabels``): the names +and map positions of constituencies and local authorities by code. The model +and data versions, the SPM provenance and which optional sections the result +holds come from its report run. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.macro_output import ( + SingleYearMacroOutput, +) +from sqlalchemy import select + +from policyengine_simulation_persistence.economy_result_storage import SIDES +from policyengine_simulation_persistence.result_tables import ( + UK_AREA_DISCLOSURE_COLUMNS, + budget_impacts, + cliff_impacts, + congressional_district_impacts, + constituency_impacts, + decile_impacts, + inequality, + intra_decile_impacts, + labor_supply_decile_impacts, + labor_supply_responses, + local_authority_impacts, + poverty_rates, + program_budget_impacts, +) +from policyengine_simulation_persistence.runs import ( + ReportRunRecord, + RunStatus, + report_runs, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from uuid import UUID + + from sqlalchemy import Connection, RowMapping, Table + + from policyengine_simulation_persistence.economy_result_storage import ( + EconomyResultLabels, + GeographyLabel, + ) + + +# --- Reading ---------------------------------------------------------------- + + +def _rows( + connection: Connection, + table: Table, + key_column: str, + key: UUID, + order: Sequence[str] = (), +) -> list[RowMapping]: + statement = select(table).where(table.c[key_column] == key) + if order: + statement = statement.order_by(*(table.c[name] for name in order)) + return list(connection.execute(statement).mappings()) + + +def _one( + connection: Connection, table: Table, key_column: str, key: UUID +) -> RowMapping: + rows = _rows(connection, table, key_column, key) + if len(rows) != 1: + msg = f"{table.name} has no row for {key}" + raise LookupError(msg) + return rows[0] + + +def _poverty(rows_by_side: dict[str, list[RowMapping]]) -> dict[str, Any]: + sections: dict[tuple[str, str], dict[str, dict[str, float]]] = {} + for side, side_rows in rows_by_side.items(): + for row in side_rows: + group = sections.setdefault((row["group_dimension"], row["measure"]), {}) + group.setdefault(row["group_value"], {})[side] = row["rate"] + return { + "poverty": { + "poverty": sections[("age", "poverty")], + "deep_poverty": sections[("age", "deep_poverty")], + }, + "poverty_by_gender": { + "poverty": sections[("gender", "poverty")], + "deep_poverty": sections[("gender", "deep_poverty")], + }, + "poverty_by_race": ( + {"poverty": sections[("race", "poverty")]} + if ("race", "poverty") in sections + else None + ), + } + + +def _figures( + connection: Connection, + table: Table, + report_run_id: UUID, + run_id: UUID, + side: str, +) -> list[RowMapping]: + """Return one side's own figures as one report run computed them.""" + return list( + connection.execute( + select(table).where( + table.c.report_run_id == report_run_id, + table.c.simulation_run_id == run_id, + table.c.side == side, + ) + ).mappings() + ) + + +def _one_figure( + connection: Connection, + table: Table, + report_run_id: UUID, + run_id: UUID, + side: str, +) -> RowMapping: + rows = _figures(connection, table, report_run_id, run_id, side) + if len(rows) != 1: + msg = ( + f"{table.name} has no {side} row for run {run_id} " + f"in report run {report_run_id}" + ) + raise LookupError(msg) + return rows[0] + + +def _simulation_sections( + connection: Connection, + report_run_id: UUID, + run_ids: tuple[UUID, UUID], + sections: set[str], +) -> dict[str, Any]: + by_side = { + side: _figures(connection, poverty_rates, report_run_id, run_id, side) + for side, run_id in zip(SIDES, run_ids, strict=True) + } + payload = _poverty(by_side) + if "poverty_by_race" not in sections: + payload["poverty_by_race"] = None + inequality_rows = [ + _one_figure(connection, inequality, report_run_id, run_id, side) + for side, run_id in zip(SIDES, run_ids, strict=True) + ] + payload["inequality"] = { + name: { + side: row[name] for side, row in zip(SIDES, inequality_rows, strict=True) + } + for name in ("gini", "top_10_pct_share", "top_1_pct_share") + } + payload["cliff_impact"] = None + if "cliff_impact" in sections: + cliff_rows = [ + _one_figure(connection, cliff_impacts, report_run_id, run_id, side) + for side, run_id in zip(SIDES, run_ids, strict=True) + ] + payload["cliff_impact"] = { + side: {name: row[name] for name in ("cliff_gap", "cliff_share")} + for side, row in zip(SIDES, cliff_rows, strict=True) + } + return payload + + +def _deciles(rows: list[RowMapping], measure: str) -> dict[str, dict[str, float]]: + measured = [row for row in rows if row["measure"] == measure] + return { + "average": {row["decile"]: row["average_change"] for row in measured}, + "relative": {row["decile"]: row["relative_change"] for row in measured}, + } + + +def _intra_deciles(rows: list[RowMapping], measure: str) -> dict[str, Any]: + """Rebuild one breakdown; its categories come from their average rows. + + A category whose decile shares are missing (the breakdown failed) gets an + empty list, as it was written. + """ + measured = [row for row in rows if row["measure"] == measure] + averages = sorted( + (row for row in measured if row["decile"] is None), + key=lambda row: row["outcome_position"], + ) + deciles: dict[str, list[float]] = {row["outcome"]: [] for row in averages} + for row in sorted( + (row for row in measured if row["decile"] is not None), + key=lambda row: (row["outcome_position"], row["decile"]), + ): + deciles[row["outcome"]].append(row["share"]) + return { + "deciles": deciles, + "all": {row["outcome"]: row["share"] for row in averages}, + } + + +def _labor_supply_decile_maps( + rows: list[RowMapping], statistic: str +) -> dict[str, dict[int, float]]: + """Rebuild one pair of labour supply decile maps in its own order.""" + named = sorted( + (row for row in rows if row[f"{statistic}_position"] is not None), + key=lambda row: row[f"{statistic}_position"], + ) + return { + effect: {row["decile"]: row[f"{statistic}_{effect}_effect"] for row in named} + for effect in ("income", "substitution") + } + + +def _labor_supply(connection: Connection, report_run_id: UUID) -> dict[str, Any]: + response = _one(connection, labor_supply_responses, "report_run_id", report_run_id) + deciles = _rows( + connection, labor_supply_decile_impacts, "report_run_id", report_run_id + ) + return { + "substitution_lsr": response["substitution_lsr"], + "income_lsr": response["income_lsr"], + "relative_lsr": { + "income": response["relative_income_response"], + "substitution": response["relative_substitution_response"], + }, + "total_change": response["total_change"], + "revenue_change": response["revenue_change"], + "decile": { + statistic: _labor_supply_decile_maps(deciles, statistic) + for statistic in ("average", "relative") + }, + "hours": { + "baseline": response["baseline_hours"], + "reform": response["reform_hours"], + "change": response["hours_change"], + "income_effect": response["hours_income_effect"], + "substitution_effect": response["hours_substitution_effect"], + }, + } + + +def _labelled_geographies( + rows: list[RowMapping], + code_field: str, + name_field: str, + labels: Mapping[str, GeographyLabel], +) -> list[dict[str, Any]]: + records = [] + for row in rows: + code = row[code_field] + label = labels.get(code) + if label is None: + msg = f"the region catalog has no label for {code}" + raise LookupError(msg) + records.append( + { + code_field: code, + name_field: label.name, + "x": label.x, + "y": label.y, + "average_household_income_change": row[ + "average_household_income_change" + ], + "relative_household_income_change": row[ + "relative_household_income_change" + ], + "population": row["population"], + **{name: row[name] for name in UK_AREA_DISCLOSURE_COLUMNS}, + } + ) + return records + + +def _geography_sections( + connection: Connection, + report_run_id: UUID, + sections: set[str], + labels: EconomyResultLabels, +) -> dict[str, Any]: + def ordered(table: Table) -> list[RowMapping]: + return _rows( + connection, table, "report_run_id", report_run_id, order=("position",) + ) + + payload: dict[str, Any] = { + "congressional_district_impact": None, + "constituency_impact": None, + "local_authority_impact": None, + } + if "congressional_district_impact" in sections: + payload["congressional_district_impact"] = { + "districts": [ + { + name: row[name] + for name in row.keys() # noqa: SIM118 # RowMapping keys + if name not in {"report_run_id", "position"} + } + for row in ordered(congressional_district_impacts) + ] + } + if "constituency_impact" in sections: + payload["constituency_impact"] = _labelled_geographies( + ordered(constituency_impacts), + "constituency_code", + "constituency_name", + labels.constituencies, + ) + if "local_authority_impact" in sections: + payload["local_authority_impact"] = _labelled_geographies( + ordered(local_authority_impacts), + "local_authority_code", + "local_authority_name", + labels.local_authorities, + ) + return payload + + +def _comparison_sections( + connection: Connection, + report_run_id: UUID, + sections: set[str], + labels: EconomyResultLabels, +) -> dict[str, Any]: + budget = _one(connection, budget_impacts, "report_run_id", report_run_id) + deciles = _rows( + connection, + decile_impacts, + "report_run_id", + report_run_id, + order=("measure", "position"), + ) + intra = _rows(connection, intra_decile_impacts, "report_run_id", report_run_id) + programs = _rows( + connection, + program_budget_impacts, + "report_run_id", + report_run_id, + order=("position",), + ) + return { + "budget": { + name: value for name, value in budget.items() if name != "report_run_id" + }, + "detailed_budget": { + row["program"]: { + "baseline": row["baseline"], + "reform": row["reform"], + "difference": row["difference"], + } + for row in programs + }, + "decile": _deciles(deciles, "income"), + "wealth_decile": ( + _deciles(deciles, "wealth") if "wealth_decile" in sections else None + ), + "intra_decile": _intra_deciles(intra, "income"), + "intra_wealth_decile": ( + _intra_deciles(intra, "wealth") + if "intra_wealth_decile" in sections + else None + ), + "labor_supply_response": ( + _labor_supply(connection, report_run_id) + if "labor_supply_response" in sections + else None + ), + **_geography_sections(connection, report_run_id, sections, labels), + } + + +def read_economy_comparison( + connection: Connection, report_run_id: UUID, labels: EconomyResultLabels +) -> SingleYearMacroOutput: + """Rebuild a succeeded economy report run's result from its rows. + + Raise LookupError when the report run has no succeeded comparison, or + when the labels lack a constituency or local authority the result names. + """ + report_run_row = ( + connection.execute(select(report_runs).where(report_runs.c.id == report_run_id)) + .mappings() + .first() + ) + if report_run_row is None: + msg = f"no report run {report_run_id}" + raise LookupError(msg) + report_run = ReportRunRecord.from_row(report_run_row) + if ( + report_run.comparison_status is not RunStatus.SUCCEEDED + or report_run.reform_simulation_run_id is None + ): + msg = f"report run {report_run_id} has no succeeded economy comparison" + raise LookupError(msg) + sections: set[str] = set(report_run_row["result_sections"] or ()) + run_ids = ( + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + ) + return SingleYearMacroOutput.model_validate( + { + "spm_provenance": report_run_row["spm_provenance"], + "model_version": report_run_row["result_model_version"], + "data_version": report_run_row["result_data_version"], + **_comparison_sections(connection, report_run_id, sections, labels), + **_simulation_sections(connection, report_run_id, run_ids, sections), + } + ) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_storage.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_storage.py new file mode 100644 index 000000000..4f09d324a --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_result_storage.py @@ -0,0 +1,188 @@ +"""Where each part of an economy result is stored, and its catalog labels. + +``RESULT_FIELD_STORAGE`` names the column (or catalog label) that holds each +field of ``SingleYearMacroOutput``; a test requires it to name every field. +``EconomyResultLabels`` carries what a stored result takes from the region +catalog when it is read: constituency and local-authority names and map +positions, by code. The writer (``economy_results``) and the reader +(``economy_result_reader``) share ``OPTIONAL_SECTIONS`` and ``SIDES``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence.result_tables import ( + POVERTY_GROUPS, + UK_AREA_DISCLOSURE_COLUMNS, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + +# The sections a result may leave null. Which of them a stored result holds is +# recorded on its report run, so a reader returns null exactly where the +# writer received null, including for empty lists. +OPTIONAL_SECTIONS = ( + "poverty_by_race", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact", + "congressional_district_impact", + "cliff_impact", +) +SIDES = ("baseline", "reform") + + +# Where each field of SingleYearMacroOutput is kept. "{key}" stands for a map +# key and "[]" for a list item; per-simulation figures are kept on the +# baseline's or the reform's own simulation run, keyed by the report run and +# the side too. Labels come from the catalog +# when the result is read. A test requires this to name every field. +REFUSED_OTHER_KIND = "refused by check_storable: a record of the other kind" +_GEOGRAPHY_FIELDS = ( + "average_household_income_change", + "relative_household_income_change", + "population", + *UK_AREA_DISCLOSURE_COLUMNS, +) + + +def _poverty_storage() -> dict[str, str]: + paths = {} + for section, measures, groups in ( + ("poverty", ("poverty", "deep_poverty"), POVERTY_GROUPS["age"]), + ("poverty_by_gender", ("poverty", "deep_poverty"), POVERTY_GROUPS["gender"]), + ("poverty_by_race", ("poverty",), POVERTY_GROUPS["race"]), + ): + for measure in measures: + for group in groups: + for side in SIDES: + paths[f"{section}.{measure}.{group}.{side}"] = "poverty_rates.rate" + return paths + + +def _geography_storage( + section: str, own: str, other: str, table: str +) -> dict[str, str]: + paths = {f"{section}[].{name}": f"{table}.{name}" for name in _GEOGRAPHY_FIELDS} + paths[f"{section}[].{own}_code"] = f"{table}.{own}_code" + paths[f"{section}[].{own}_name"] = "region catalog: name" + paths[f"{section}[].x"] = "region catalog: map x" + paths[f"{section}[].y"] = "region catalog: map y" + paths[f"{section}[].{other}_code"] = REFUSED_OTHER_KIND + paths[f"{section}[].{other}_name"] = REFUSED_OTHER_KIND + return paths + + +RESULT_FIELD_STORAGE: dict[str, str] = { + "spm_provenance": "report_runs.spm_provenance", + "model_version": "report_runs.result_model_version", + "data_version": "report_runs.result_data_version", + **{ + f"budget.{name}": f"budget_impacts.{name}" + for name in ( + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income", + ) + }, + **{ + f"detailed_budget.{{key}}.{name}": f"program_budget_impacts.{name}" + for name in ("baseline", "reform", "difference") + }, + **{ + f"{section}.{statistic}.{{key}}": f"decile_impacts.{statistic}_change" + for section in ("decile", "wealth_decile") + for statistic in ("average", "relative") + }, + **{ + path: "intra_decile_impacts.share" + for section in ("intra_decile", "intra_wealth_decile") + for path in (f"{section}.deciles.{{key}}[]", f"{section}.all.{{key}}") + }, + **{ + f"inequality.{name}.{side}": f"inequality.{name}" + for name in ("gini", "top_10_pct_share", "top_1_pct_share") + for side in SIDES + }, + **_poverty_storage(), + **{ + f"cliff_impact.{side}.{name}": f"cliff_impacts.{name}" + for side in SIDES + for name in ("cliff_gap", "cliff_share") + }, + **{ + f"labor_supply_response.{path}": f"labor_supply_responses.{column}" + for path, column in ( + ("substitution_lsr", "substitution_lsr"), + ("income_lsr", "income_lsr"), + ("relative_lsr.income", "relative_income_response"), + ("relative_lsr.substitution", "relative_substitution_response"), + ("total_change", "total_change"), + ("revenue_change", "revenue_change"), + ("hours.baseline", "baseline_hours"), + ("hours.reform", "reform_hours"), + ("hours.change", "hours_change"), + ("hours.income_effect", "hours_income_effect"), + ("hours.substitution_effect", "hours_substitution_effect"), + ) + }, + **{ + f"labor_supply_response.decile.{statistic}.{effect}.{{key}}": ( + f"labor_supply_decile_impacts.{statistic}_{effect}_effect" + ) + for statistic in ("average", "relative") + for effect in ("income", "substitution") + }, + **{ + f"congressional_district_impact.districts[].{name}": ( + f"congressional_district_impacts.{name}" + ) + for name in ( + "district", + "average_household_income_change", + "relative_household_income_change", + "winner_percentage", + "loser_percentage", + "no_change_percentage", + "population", + ) + }, + **_geography_storage( + "constituency_impact", "constituency", "local_authority", "constituency_impacts" + ), + **_geography_storage( + "local_authority_impact", + "local_authority", + "constituency", + "local_authority_impacts", + ), +} + + +@dataclass(frozen=True) +class GeographyLabel: + """A constituency's or local authority's name and map position.""" + + name: str + x: int | None + y: int | None + + +@dataclass(frozen=True) +class EconomyResultLabels: + """What a stored result takes from the region catalog when it is read. + + Constituency and local-authority names and map positions, by code. + """ + + constituencies: Mapping[str, GeographyLabel] = field(default_factory=dict) + local_authorities: Mapping[str, GeographyLabel] = field(default_factory=dict) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py index 8a46ec363..499e3ca68 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -21,7 +21,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from policyengine_simulation_contract.macro_output import ( @@ -33,11 +33,21 @@ LocalAuthorityImpactRecord, SingleYearMacroOutput, ) -from sqlalchemy import select, update +from sqlalchemy import update from sqlalchemy.dialects.postgresql import insert as postgres_insert +from policyengine_simulation_persistence.economy_result_reader import ( + read_economy_comparison, +) +from policyengine_simulation_persistence.economy_result_storage import ( + OPTIONAL_SECTIONS, + REFUSED_OTHER_KIND, + RESULT_FIELD_STORAGE, + SIDES, + EconomyResultLabels, + GeographyLabel, +) from policyengine_simulation_persistence.result_tables import ( - POVERTY_GROUPS, UK_AREA_DISCLOSURE_COLUMNS, budget_impacts, cliff_impacts, @@ -54,181 +64,26 @@ ) from policyengine_simulation_persistence.runs import ( ReportRunRecord, - RunStatus, report_runs, ) if TYPE_CHECKING: - from collections.abc import Mapping, Sequence from uuid import UUID - from sqlalchemy import Connection, RowMapping, Table - -# The sections a result may leave null. Which of them a stored result holds is -# recorded on its report run, so a reader returns null exactly where the -# writer received null, including for empty lists. -OPTIONAL_SECTIONS = ( - "poverty_by_race", - "wealth_decile", - "intra_wealth_decile", - "labor_supply_response", - "constituency_impact", - "local_authority_impact", - "congressional_district_impact", - "cliff_impact", -) -SIDES = ("baseline", "reform") - - -# Where each field of SingleYearMacroOutput is kept. "{key}" stands for a map -# key and "[]" for a list item; per-simulation figures are kept on the -# baseline's or the reform's own simulation run, keyed by the report run and -# the side too. Labels come from the catalog -# when the result is read. A test requires this to name every field. -REFUSED_OTHER_KIND = "refused by check_storable: a record of the other kind" -_GEOGRAPHY_FIELDS = ( - "average_household_income_change", - "relative_household_income_change", - "population", - *UK_AREA_DISCLOSURE_COLUMNS, -) - - -def _poverty_storage() -> dict[str, str]: - paths = {} - for section, measures, groups in ( - ("poverty", ("poverty", "deep_poverty"), POVERTY_GROUPS["age"]), - ("poverty_by_gender", ("poverty", "deep_poverty"), POVERTY_GROUPS["gender"]), - ("poverty_by_race", ("poverty",), POVERTY_GROUPS["race"]), - ): - for measure in measures: - for group in groups: - for side in SIDES: - paths[f"{section}.{measure}.{group}.{side}"] = "poverty_rates.rate" - return paths - - -def _geography_storage( - section: str, own: str, other: str, table: str -) -> dict[str, str]: - paths = {f"{section}[].{name}": f"{table}.{name}" for name in _GEOGRAPHY_FIELDS} - paths[f"{section}[].{own}_code"] = f"{table}.{own}_code" - paths[f"{section}[].{own}_name"] = "region catalog: name" - paths[f"{section}[].x"] = "region catalog: map x" - paths[f"{section}[].y"] = "region catalog: map y" - paths[f"{section}[].{other}_code"] = REFUSED_OTHER_KIND - paths[f"{section}[].{other}_name"] = REFUSED_OTHER_KIND - return paths - - -RESULT_FIELD_STORAGE: dict[str, str] = { - "spm_provenance": "report_runs.spm_provenance", - "model_version": "report_runs.result_model_version", - "data_version": "report_runs.result_data_version", - **{ - f"budget.{name}": f"budget_impacts.{name}" - for name in ( - "budgetary_impact", - "tax_revenue_impact", - "state_tax_revenue_impact", - "benefit_spending_impact", - "households", - "baseline_net_income", - ) - }, - **{ - f"detailed_budget.{{key}}.{name}": f"program_budget_impacts.{name}" - for name in ("baseline", "reform", "difference") - }, - **{ - f"{section}.{statistic}.{{key}}": f"decile_impacts.{statistic}_change" - for section in ("decile", "wealth_decile") - for statistic in ("average", "relative") - }, - **{ - path: "intra_decile_impacts.share" - for section in ("intra_decile", "intra_wealth_decile") - for path in (f"{section}.deciles.{{key}}[]", f"{section}.all.{{key}}") - }, - **{ - f"inequality.{name}.{side}": f"inequality.{name}" - for name in ("gini", "top_10_pct_share", "top_1_pct_share") - for side in SIDES - }, - **_poverty_storage(), - **{ - f"cliff_impact.{side}.{name}": f"cliff_impacts.{name}" - for side in SIDES - for name in ("cliff_gap", "cliff_share") - }, - **{ - f"labor_supply_response.{path}": f"labor_supply_responses.{column}" - for path, column in ( - ("substitution_lsr", "substitution_lsr"), - ("income_lsr", "income_lsr"), - ("relative_lsr.income", "relative_income_response"), - ("relative_lsr.substitution", "relative_substitution_response"), - ("total_change", "total_change"), - ("revenue_change", "revenue_change"), - ("hours.baseline", "baseline_hours"), - ("hours.reform", "reform_hours"), - ("hours.change", "hours_change"), - ("hours.income_effect", "hours_income_effect"), - ("hours.substitution_effect", "hours_substitution_effect"), - ) - }, - **{ - f"labor_supply_response.decile.{statistic}.{effect}.{{key}}": ( - f"labor_supply_decile_impacts.{statistic}_{effect}_effect" - ) - for statistic in ("average", "relative") - for effect in ("income", "substitution") - }, - **{ - f"congressional_district_impact.districts[].{name}": ( - f"congressional_district_impacts.{name}" - ) - for name in ( - "district", - "average_household_income_change", - "relative_household_income_change", - "winner_percentage", - "loser_percentage", - "no_change_percentage", - "population", - ) - }, - **_geography_storage( - "constituency_impact", "constituency", "local_authority", "constituency_impacts" - ), - **_geography_storage( - "local_authority_impact", - "local_authority", - "constituency", - "local_authority_impacts", - ), -} - - -@dataclass(frozen=True) -class GeographyLabel: - """A constituency's or local authority's name and map position.""" - - name: str - x: int | None - y: int | None - - -@dataclass(frozen=True) -class EconomyResultLabels: - """What a stored result takes from the region catalog when it is read. - - Constituency and local-authority names and map positions, by code. - """ - - constituencies: Mapping[str, GeographyLabel] = field(default_factory=dict) - local_authorities: Mapping[str, GeographyLabel] = field(default_factory=dict) - + from sqlalchemy import Connection, Table + +__all__ = [ + "OPTIONAL_SECTIONS", + "REFUSED_OTHER_KIND", + "RESULT_FIELD_STORAGE", + "SIDES", + "EconomyComparisonRecorder", + "EconomyResultLabels", + "GeographyLabel", + "check_storable", + "read_economy_comparison", + "write_economy_comparison", +] # --- Checks before writing ------------------------------------------------ @@ -601,365 +456,3 @@ def write( msg = "the comparison recorder writes only validated economy results" raise TypeError(msg) write_economy_comparison(connection, report_run, result) - - -# --- Reading ---------------------------------------------------------------- - - -def _rows( - connection: Connection, - table: Table, - key_column: str, - key: UUID, - order: Sequence[str] = (), -) -> list[RowMapping]: - statement = select(table).where(table.c[key_column] == key) - if order: - statement = statement.order_by(*(table.c[name] for name in order)) - return list(connection.execute(statement).mappings()) - - -def _one( - connection: Connection, table: Table, key_column: str, key: UUID -) -> RowMapping: - rows = _rows(connection, table, key_column, key) - if len(rows) != 1: - msg = f"{table.name} has no row for {key}" - raise LookupError(msg) - return rows[0] - - -def _poverty(rows_by_side: dict[str, list[RowMapping]]) -> dict[str, Any]: - sections: dict[tuple[str, str], dict[str, dict[str, float]]] = {} - for side, side_rows in rows_by_side.items(): - for row in side_rows: - group = sections.setdefault((row["group_dimension"], row["measure"]), {}) - group.setdefault(row["group_value"], {})[side] = row["rate"] - return { - "poverty": { - "poverty": sections[("age", "poverty")], - "deep_poverty": sections[("age", "deep_poverty")], - }, - "poverty_by_gender": { - "poverty": sections[("gender", "poverty")], - "deep_poverty": sections[("gender", "deep_poverty")], - }, - "poverty_by_race": ( - {"poverty": sections[("race", "poverty")]} - if ("race", "poverty") in sections - else None - ), - } - - -def _figures( - connection: Connection, - table: Table, - report_run_id: UUID, - run_id: UUID, - side: str, -) -> list[RowMapping]: - """Return one side's own figures as one report run computed them.""" - return list( - connection.execute( - select(table).where( - table.c.report_run_id == report_run_id, - table.c.simulation_run_id == run_id, - table.c.side == side, - ) - ).mappings() - ) - - -def _one_figure( - connection: Connection, - table: Table, - report_run_id: UUID, - run_id: UUID, - side: str, -) -> RowMapping: - rows = _figures(connection, table, report_run_id, run_id, side) - if len(rows) != 1: - msg = ( - f"{table.name} has no {side} row for run {run_id} " - f"in report run {report_run_id}" - ) - raise LookupError(msg) - return rows[0] - - -def _simulation_sections( - connection: Connection, - report_run_id: UUID, - run_ids: tuple[UUID, UUID], - sections: set[str], -) -> dict[str, Any]: - by_side = { - side: _figures(connection, poverty_rates, report_run_id, run_id, side) - for side, run_id in zip(SIDES, run_ids, strict=True) - } - payload = _poverty(by_side) - if "poverty_by_race" not in sections: - payload["poverty_by_race"] = None - inequality_rows = [ - _one_figure(connection, inequality, report_run_id, run_id, side) - for side, run_id in zip(SIDES, run_ids, strict=True) - ] - payload["inequality"] = { - name: { - side: row[name] for side, row in zip(SIDES, inequality_rows, strict=True) - } - for name in ("gini", "top_10_pct_share", "top_1_pct_share") - } - payload["cliff_impact"] = None - if "cliff_impact" in sections: - cliff_rows = [ - _one_figure(connection, cliff_impacts, report_run_id, run_id, side) - for side, run_id in zip(SIDES, run_ids, strict=True) - ] - payload["cliff_impact"] = { - side: {name: row[name] for name in ("cliff_gap", "cliff_share")} - for side, row in zip(SIDES, cliff_rows, strict=True) - } - return payload - - -def _deciles(rows: list[RowMapping], measure: str) -> dict[str, dict[str, float]]: - measured = [row for row in rows if row["measure"] == measure] - return { - "average": {row["decile"]: row["average_change"] for row in measured}, - "relative": {row["decile"]: row["relative_change"] for row in measured}, - } - - -def _intra_deciles(rows: list[RowMapping], measure: str) -> dict[str, Any]: - """Rebuild one breakdown; its categories come from their average rows. - - A category whose decile shares are missing (the breakdown failed) gets an - empty list, as it was written. - """ - measured = [row for row in rows if row["measure"] == measure] - averages = sorted( - (row for row in measured if row["decile"] is None), - key=lambda row: row["outcome_position"], - ) - deciles: dict[str, list[float]] = {row["outcome"]: [] for row in averages} - for row in sorted( - (row for row in measured if row["decile"] is not None), - key=lambda row: (row["outcome_position"], row["decile"]), - ): - deciles[row["outcome"]].append(row["share"]) - return { - "deciles": deciles, - "all": {row["outcome"]: row["share"] for row in averages}, - } - - -def _labor_supply_decile_maps( - rows: list[RowMapping], statistic: str -) -> dict[str, dict[int, float]]: - """Rebuild one pair of labour supply decile maps in its own order.""" - named = sorted( - (row for row in rows if row[f"{statistic}_position"] is not None), - key=lambda row: row[f"{statistic}_position"], - ) - return { - effect: {row["decile"]: row[f"{statistic}_{effect}_effect"] for row in named} - for effect in ("income", "substitution") - } - - -def _labor_supply(connection: Connection, report_run_id: UUID) -> dict[str, Any]: - response = _one(connection, labor_supply_responses, "report_run_id", report_run_id) - deciles = _rows( - connection, labor_supply_decile_impacts, "report_run_id", report_run_id - ) - return { - "substitution_lsr": response["substitution_lsr"], - "income_lsr": response["income_lsr"], - "relative_lsr": { - "income": response["relative_income_response"], - "substitution": response["relative_substitution_response"], - }, - "total_change": response["total_change"], - "revenue_change": response["revenue_change"], - "decile": { - statistic: _labor_supply_decile_maps(deciles, statistic) - for statistic in ("average", "relative") - }, - "hours": { - "baseline": response["baseline_hours"], - "reform": response["reform_hours"], - "change": response["hours_change"], - "income_effect": response["hours_income_effect"], - "substitution_effect": response["hours_substitution_effect"], - }, - } - - -def _labelled_geographies( - rows: list[RowMapping], - code_field: str, - name_field: str, - labels: Mapping[str, GeographyLabel], -) -> list[dict[str, Any]]: - records = [] - for row in rows: - code = row[code_field] - label = labels.get(code) - if label is None: - msg = f"the region catalog has no label for {code}" - raise LookupError(msg) - records.append( - { - code_field: code, - name_field: label.name, - "x": label.x, - "y": label.y, - "average_household_income_change": row[ - "average_household_income_change" - ], - "relative_household_income_change": row[ - "relative_household_income_change" - ], - "population": row["population"], - **{name: row[name] for name in UK_AREA_DISCLOSURE_COLUMNS}, - } - ) - return records - - -def _geography_sections( - connection: Connection, - report_run_id: UUID, - sections: set[str], - labels: EconomyResultLabels, -) -> dict[str, Any]: - def ordered(table: Table) -> list[RowMapping]: - return _rows( - connection, table, "report_run_id", report_run_id, order=("position",) - ) - - payload: dict[str, Any] = { - "congressional_district_impact": None, - "constituency_impact": None, - "local_authority_impact": None, - } - if "congressional_district_impact" in sections: - payload["congressional_district_impact"] = { - "districts": [ - { - name: row[name] - for name in row.keys() # noqa: SIM118 # RowMapping keys - if name not in {"report_run_id", "position"} - } - for row in ordered(congressional_district_impacts) - ] - } - if "constituency_impact" in sections: - payload["constituency_impact"] = _labelled_geographies( - ordered(constituency_impacts), - "constituency_code", - "constituency_name", - labels.constituencies, - ) - if "local_authority_impact" in sections: - payload["local_authority_impact"] = _labelled_geographies( - ordered(local_authority_impacts), - "local_authority_code", - "local_authority_name", - labels.local_authorities, - ) - return payload - - -def _comparison_sections( - connection: Connection, - report_run_id: UUID, - sections: set[str], - labels: EconomyResultLabels, -) -> dict[str, Any]: - budget = _one(connection, budget_impacts, "report_run_id", report_run_id) - deciles = _rows( - connection, - decile_impacts, - "report_run_id", - report_run_id, - order=("measure", "position"), - ) - intra = _rows(connection, intra_decile_impacts, "report_run_id", report_run_id) - programs = _rows( - connection, - program_budget_impacts, - "report_run_id", - report_run_id, - order=("position",), - ) - return { - "budget": { - name: value for name, value in budget.items() if name != "report_run_id" - }, - "detailed_budget": { - row["program"]: { - "baseline": row["baseline"], - "reform": row["reform"], - "difference": row["difference"], - } - for row in programs - }, - "decile": _deciles(deciles, "income"), - "wealth_decile": ( - _deciles(deciles, "wealth") if "wealth_decile" in sections else None - ), - "intra_decile": _intra_deciles(intra, "income"), - "intra_wealth_decile": ( - _intra_deciles(intra, "wealth") - if "intra_wealth_decile" in sections - else None - ), - "labor_supply_response": ( - _labor_supply(connection, report_run_id) - if "labor_supply_response" in sections - else None - ), - **_geography_sections(connection, report_run_id, sections, labels), - } - - -def read_economy_comparison( - connection: Connection, report_run_id: UUID, labels: EconomyResultLabels -) -> SingleYearMacroOutput: - """Rebuild a succeeded economy report run's result from its rows. - - Raise LookupError when the report run has no succeeded comparison, or - when the labels lack a constituency or local authority the result names. - """ - report_run_row = ( - connection.execute(select(report_runs).where(report_runs.c.id == report_run_id)) - .mappings() - .first() - ) - if report_run_row is None: - msg = f"no report run {report_run_id}" - raise LookupError(msg) - report_run = ReportRunRecord.from_row(report_run_row) - if ( - report_run.comparison_status is not RunStatus.SUCCEEDED - or report_run.reform_simulation_run_id is None - ): - msg = f"report run {report_run_id} has no succeeded economy comparison" - raise LookupError(msg) - sections: set[str] = set(report_run_row["result_sections"] or ()) - run_ids = ( - report_run.baseline_simulation_run_id, - report_run.reform_simulation_run_id, - ) - return SingleYearMacroOutput.model_validate( - { - "spm_provenance": report_run_row["spm_provenance"], - "model_version": report_run_row["result_model_version"], - "data_version": report_run_row["result_data_version"], - **_comparison_sections(connection, report_run_id, sections, labels), - **_simulation_sections(connection, report_run_id, run_ids, sections), - } - ) From dfd9b653fdad6047a8ae55f61cc1266d2cf6790b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:19:33 +0400 Subject: [PATCH 095/124] Find household simulations by their household, policy and options A simulation gains options: whatever else changes what it calculates, such as a household's axes, stored as a JSON object and empty unless set (revision 0005). HouseholdCalculationStore.find_or_create_simulation returns the oldest household simulation with the same country, household, policy, dynamic and options, compared by value, or creates it, so a household calculation submitted again reuses its simulation and run. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 13 +- .../__init__.py | 4 + .../household_calculations.py | 79 ++++++++++ .../schema.py | 2 +- .../simulations.py | 21 ++- .../tests/test_definitions.py | 10 ++ .../tests/test_household_calculations.py | 136 ++++++++++++++++++ .../versions/0005_household_calculations.py | 52 +++++++ 8 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_household_calculations.py create mode 100644 migrations/versions/0005_household_calculations.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 8b6d26e54..000e7a3b0 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -12,7 +12,7 @@ writes them with DML only. | Table | Holds | | --- | --- | -| `simulations` | What to run, independent of model versions: kind (`economy` or `household`), country, policy (null for current law), dynamic, and either a household or a region code (`state/ca`) with an optional dataset name (null means the region's default dataset in each catalog version) | +| `simulations` | What to run, independent of model versions: kind (`economy` or `household`), country, policy (null for current law), dynamic, either a household or a region code (`state/ca`) with an optional dataset name (null means the region's default dataset in each catalog version), and `options`, whatever else changes what it calculates, such as a household's axes (empty unless set) | | `reports` | Which two simulations a report compares (an economy report needs a reform; a household report may have only a baseline), both of the report's own country and kind, with its year and options | | `simulation_runs` | One execution of a simulation for a catalog version and year: the region code and dataset row it resolved in that version (economy runs only), its status, Modal call ID, timings, error, trigger (`initial`, `manual` or `system`) and attempt number | | `report_runs` | One calculation of a report: a baseline and a reform simulation run of the same catalog version and year (no reform run for a single-simulation household report), the trigger, and, for an economy report, the comparison step's own status and Modal call | @@ -30,6 +30,17 @@ its catalog version) are `ON DELETE RESTRICT`. Creating them needs `REFERENCES` on those tables for the migrator role; the runtime role needs no privilege on them. +### Household calculations + +A household calculation is one simulation run of a household simulation. +`HouseholdCalculationStore.find_or_create_simulation` returns the oldest +household simulation with the same country, household, policy, dynamic and +options (compared by value), or creates it. The Public API's households and +policies never change once stored, so a calculation submitted again finds +the same simulation, and `RunStore.start_simulation_run` reuses its run. +Two racing submissions may each create the simulation; later ones find the +older row. + ### Runs A simulation run's setup is its catalog version (one policyengine.py bundle diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 2bee7a378..8ba43f9e0 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -19,6 +19,9 @@ normalized_database_url, read_schema_revision, ) +from policyengine_simulation_persistence.household_calculations import ( + HouseholdCalculationStore, +) from policyengine_simulation_persistence.household_results import ( HouseholdOutput, read_household_output, @@ -85,6 +88,7 @@ "EconomyComparisonRecorder", "EconomyResultLabels", "GeographyLabel", + "HouseholdCalculationStore", "HouseholdOutput", "NewReport", "NewReportRun", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py new file mode 100644 index 000000000..4af4cdce9 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py @@ -0,0 +1,79 @@ +"""Household calculations: household simulations found by what they calculate. + +A household calculation is one simulation run of a household simulation: one +household under one policy (null for current law), with an optional dynamic +and the options that change what it calculates, such as its axes. The Public +API's households and policies never change once stored, so those keys identify +the calculation's inputs, and a calculation submitted again finds the same +simulation and, through ``RunStore``, reuses its run. + +Two submissions racing may each create the simulation; both rows describe the +same calculation, and later submissions find the older one. That costs one +calculation and no wrong result, so no unique constraint is needed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import cast, select +from sqlalchemy.dialects.postgresql import JSONB + +from policyengine_simulation_persistence.simulations import ( + SimulationKind, + SimulationRecord, + SimulationStore, + simulations, +) + +if TYPE_CHECKING: + from sqlalchemy import Engine + + from policyengine_simulation_persistence.simulations import NewSimulation + + +class HouseholdCalculationStore: + """Typed SQLAlchemy Core reads and inserts for household calculations.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + self._simulations = SimulationStore(engine) + + def find_or_create_simulation( + self, new_simulation: NewSimulation + ) -> SimulationRecord: + """Return the oldest household simulation with these inputs, or create it. + + It matches the country, household, policy, dynamic and options; options + compare by value, whatever their key order. Raise ValueError for an + economy simulation. + """ + if new_simulation.kind is not SimulationKind.HOUSEHOLD: + msg = "only a household simulation is found by its household" + raise ValueError(msg) + with self.engine.connect() as connection: + row = ( + connection.execute( + select(simulations) + .where( + simulations.c.kind == SimulationKind.HOUSEHOLD.value, + simulations.c.country == new_simulation.country, + simulations.c.household_id == new_simulation.household_id, + simulations.c.policy_id.is_not_distinct_from( + new_simulation.policy_id + ), + simulations.c.dynamic_id.is_not_distinct_from( + new_simulation.dynamic_id + ), + simulations.c.options == cast(new_simulation.options, JSONB), + ) + .order_by(simulations.c.created_at, simulations.c.id) + .limit(1) + ) + .mappings() + .first() + ) + if row is not None: + return SimulationRecord.from_row(row) + return self._simulations.create(new_simulation) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py index 00ec22e10..b1cad0df4 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -17,7 +17,7 @@ # The newest revision in migrations/versions. Each migration PR updates it, and # tests/test_schema.py fails when it differs from the chain's head. The # readiness check reports it next to the database's revision. -HEAD_REVISION = "0004" +HEAD_REVISION = "0005" NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py index 76f134c26..f60ed699b 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py @@ -5,7 +5,9 @@ is a definition, not an execution, and never changes once created. A null policy means current law, and a null dataset name means the region's default dataset in whichever catalog version a run uses, so the same simulation can -run again on a newer catalog version. +run again on a newer catalog version. Its ``options`` hold whatever else +changes what it calculates, such as a household's axes; they are empty unless +set. Foreign keys into the Public API's tables (``public_api``) tie the policy and the household to the simulation's country, and name an optional dynamic. The @@ -21,6 +23,7 @@ import re from dataclasses import dataclass, fields +from dataclasses import field as dataclass_field from enum import StrEnum from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 @@ -39,7 +42,9 @@ Uuid, func, select, + text, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import insert as postgres_insert from policyengine_simulation_persistence import public_api @@ -77,6 +82,9 @@ class SimulationKind(StrEnum): Column("household_id", Uuid(as_uuid=True), nullable=True), Column("region_code", String(CATALOG_KEY_LENGTH), nullable=True), Column("dataset_name", String(CATALOG_KEY_LENGTH), nullable=True), + # What else changes the calculation, such as a household's axes; part of + # what a simulation is, so runs are reused only for the same options. + Column("options", JSONB, nullable=False, server_default=text("'{}'::jsonb")), Column("legacy_simulation_id", BigInteger, nullable=True, unique=True), Column( "created_at", @@ -103,6 +111,7 @@ class SimulationKind(StrEnum): "legacy_simulation_id IS NULL OR legacy_simulation_id > 0", name="legacy_simulation_id", ), + CheckConstraint("jsonb_typeof(options) = 'object'", name="options"), # Reports refer to a simulation together with its country and kind, so a # report can only compare simulations of its own country and kind. UniqueConstraint("id", "country", "kind"), @@ -160,6 +169,13 @@ def parse_kind[KindT: StrEnum](value: object, kind_type: type[KindT]) -> KindT: raise ValueError(msg) from None +def check_options(options: object) -> None: + """Reject options that are not a JSON object.""" + if not isinstance(options, dict): + msg = "options must be a JSON object" + raise TypeError(msg) + + def check_country(country: str) -> None: """Reject a country that is not a lowercase two-letter code.""" if not COUNTRY_PATTERN.fullmatch(country): @@ -212,6 +228,7 @@ class NewSimulation: region_code: str | None = None dataset_name: str | None = None legacy_simulation_id: int | None = None + options: dict[str, Any] = dataclass_field(default_factory=dict) def __post_init__(self) -> None: """Reject malformed fields without touching the database.""" @@ -221,6 +238,7 @@ def __post_init__(self) -> None: _check_catalog_key("region_code", self.region_code) _check_catalog_key("dataset_name", self.dataset_name) check_legacy_id("legacy_simulation_id", self.legacy_simulation_id) + check_options(self.options) @dataclass(frozen=True) @@ -236,6 +254,7 @@ class SimulationRecord: region_code: str | None dataset_name: str | None legacy_simulation_id: int | None + options: dict[str, Any] created_at: datetime @classmethod diff --git a/libs/policyengine-simulation-persistence/tests/test_definitions.py b/libs/policyengine-simulation-persistence/tests/test_definitions.py index d15f35b35..f723dadc4 100644 --- a/libs/policyengine-simulation-persistence/tests/test_definitions.py +++ b/libs/policyengine-simulation-persistence/tests/test_definitions.py @@ -72,6 +72,16 @@ def test_report_options_must_be_a_json_object() -> None: ) +def test_simulation_options_must_be_a_json_object() -> None: + with pytest.raises(TypeError, match="options must be a JSON object"): + NewSimulation( + kind=SimulationKind.HOUSEHOLD, + country="us", + household_id=uuid4(), + options=["add_missing_variables"], # type: ignore[arg-type] + ) + + def test_a_kind_given_as_text_becomes_the_enum() -> None: simulation = NewSimulation(kind="economy", country="us", region_code="state/ca") # type: ignore[arg-type] report = NewReport( diff --git a/libs/policyengine-simulation-persistence/tests/test_household_calculations.py b/libs/policyengine-simulation-persistence/tests/test_household_calculations.py new file mode 100644 index 000000000..fc00bdc2b --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_calculations.py @@ -0,0 +1,136 @@ +"""Household simulations are found by what they calculate, or created once.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_persistence import ( + HouseholdCalculationStore, + create_simulation_engine, +) +from public_api_support import superuser_engine +from simulation_support import ( + economy_simulation, + household_simulation, + simulation_store, +) +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +pytestmark = pytest.mark.postgres + +EARNINGS_AXES = [ + [{"name": "employment_income", "count": 3, "min": 0, "max": 20_000}], +] + + +def household_store(database) -> HouseholdCalculationStore: + """Return a household calculation store that connects as the runtime role.""" + return HouseholdCalculationStore(create_simulation_engine(database.runtime_url)) + + +def test_the_same_household_policy_and_options_find_one_simulation( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + definition = household_simulation( + public_api_rows, + policy_id=public_api_rows.us_policy_id, + options={"add_missing_variables": True}, + ) + + created = store.find_or_create_simulation(definition) + found = store.find_or_create_simulation(definition) + + assert found.id == created.id + assert created.options == {"add_missing_variables": True} + assert created.policy_id == public_api_rows.us_policy_id + + +def test_another_policy_household_dynamic_or_options_is_another_simulation( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + current_law = store.find_or_create_simulation(household_simulation(public_api_rows)) + variants = [ + household_simulation(public_api_rows, policy_id=public_api_rows.us_policy_id), + household_simulation(public_api_rows, dynamic_id=public_api_rows.dynamic_id), + household_simulation(public_api_rows, options={"axes": EARNINGS_AXES}), + household_simulation( + public_api_rows, country="uk", household_id=public_api_rows.uk_household_id + ), + ] + + variant_ids = {store.find_or_create_simulation(variant).id for variant in variants} + + assert len(variant_ids) == len(variants) + assert current_law.id not in variant_ids + found_again = store.find_or_create_simulation(household_simulation(public_api_rows)) + assert found_again.id == current_law.id + + +def test_options_match_by_value_whatever_their_key_order( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + + first = store.find_or_create_simulation( + household_simulation( + public_api_rows, + options={"axes": EARNINGS_AXES, "add_missing_variables": False}, + ) + ) + second = store.find_or_create_simulation( + household_simulation( + public_api_rows, + options={"add_missing_variables": False, "axes": EARNINGS_AXES}, + ) + ) + + assert second.id == first.id + + +def test_a_simulation_created_without_options_has_empty_options( + migrated_database, public_api_rows +) -> None: + created = simulation_store(migrated_database).create( + household_simulation(public_api_rows) + ) + + found = household_store(migrated_database).find_or_create_simulation( + household_simulation(public_api_rows) + ) + + assert created.options == {} + assert found.id == created.id + + +def test_only_a_household_simulation_is_found_by_its_household( + migrated_database, public_api_rows +) -> None: + with pytest.raises(ValueError, match="household simulation"): + household_store(migrated_database).find_or_create_simulation( + economy_simulation(public_api_rows) + ) + + +def test_the_database_refuses_options_that_are_not_an_object( + migrated_database, public_api_rows +) -> None: + created = simulation_store(migrated_database).create( + household_simulation(public_api_rows) + ) + engine = superuser_engine(migrated_database.superuser_url) + try: + with ( + pytest.raises(IntegrityError, match="ck_simulations_options"), + engine.begin() as connection, + ): + connection.execute( + text( + "UPDATE simulation.simulations SET options = '[]'::jsonb " + "WHERE id = :simulation_id" + ), + {"simulation_id": created.id}, + ) + finally: + engine.dispose() diff --git a/migrations/versions/0005_household_calculations.py b/migrations/versions/0005_household_calculations.py new file mode 100644 index 000000000..3db8647a9 --- /dev/null +++ b/migrations/versions/0005_household_calculations.py @@ -0,0 +1,52 @@ +"""Add what household calculations need. + +A simulation gains ``options``: whatever else changes what it calculates, +such as a household's axes, so a run is reused only for the same options. +Existing simulations get empty options. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-10-27 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add the simulation options.""" + op.add_column( + "simulations", + sa.Column( + "options", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + schema="simulation", + ) + op.create_check_constraint( + op.f("ck_simulations_options"), + "simulations", + "jsonb_typeof(options) = 'object'", + schema="simulation", + ) + + +def downgrade() -> None: + """Drop what ``upgrade`` added.""" + op.drop_constraint( + op.f("ck_simulations_options"), + "simulations", + schema="simulation", + type_="check", + ) + op.drop_column("simulations", "options", schema="simulation") From 78aef649d2cab56d6d8b1f365cb2cdf9ef8f64ac Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:22:31 +0400 Subject: [PATCH 096/124] Store a computed household's warnings, SPM choice and bundle with it A household output now keeps the calculation's warnings, in order, the complete SPM selection it applied (spm_config), its SPM receipt (spm_provenance) and the PolicyEngine.py bundle it ran with, each document null when it does not apply. They are written in the transaction that marks the run succeeded and read back unchanged; revision 0005 adds the columns. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 8 +- .../household_results.py | 54 ++++++++++++- .../tests/test_household_results.py | 78 +++++++++++++++++++ .../versions/0005_household_calculations.py | 45 ++++++++++- 4 files changed, 178 insertions(+), 7 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 000e7a3b0..bf1b05644 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -112,8 +112,12 @@ A computed household is one JSON value (`household_outputs`, PostgreSQL simulation run, with its schema version. `write_household_output` stores it in the transaction that marks the run succeeded (`RunTransitions.complete_simulation_call`); a household report's two runs -share one Modal call and each gets its own household. `read_household_output` -returns it unchanged. +share one Modal call and each gets its own household. Next to it are the +calculation's warnings, in order, and three `jsonb` documents, each null when +it does not apply: the complete SPM selection the calculation applied +(`spm_config`), its SPM receipt (`spm_provenance`) and the PolicyEngine.py +bundle it ran with (`bundle`). `read_household_output` returns it all +unchanged. `output_files` links each stored file (its role, URI and SHA-256) to the one simulation run or report run that wrote it. `available_output_files` offers a diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py index 7c1eeb764..d5cd515af 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py @@ -6,6 +6,13 @@ same numbers. ``HouseholdOutput`` checks the shape this table can hold (a JSON object and its schema version); the household functions' own result model (item 1.11) is validated before it reaches here. + +Next to it are the calculation's warnings, in order, and three documents +(``jsonb``, whose key order does not matter): the complete SPM selection the +calculation applied (``spm_config``), the SPM calculation receipt +(``spm_provenance``), and the PolicyEngine.py bundle it ran with +(``bundle``). Each is null when it does not apply, such as SPM for a UK +household. """ from __future__ import annotations @@ -24,7 +31,9 @@ Uuid, insert, select, + text, ) +from sqlalchemy.dialects.postgresql import JSONB from policyengine_simulation_persistence.runs import simulation_runs from policyengine_simulation_persistence.schema import simulation_metadata @@ -44,8 +53,20 @@ Column("simulation_run_id", Uuid(as_uuid=True), primary_key=True), Column("household", JSON(none_as_null=True), nullable=False), Column("schema_version", Integer, nullable=False), + Column("warnings", JSONB, nullable=False, server_default=text("'[]'::jsonb")), + Column("spm_config", JSONB(none_as_null=True), nullable=True), + Column("spm_provenance", JSONB(none_as_null=True), nullable=True), + Column("bundle", JSONB(none_as_null=True), nullable=True), CheckConstraint("json_typeof(household) = 'object'", name="household"), CheckConstraint("schema_version >= 1", name="schema_version"), + CheckConstraint("jsonb_typeof(warnings) = 'array'", name="warnings"), + *( + CheckConstraint( + f"{document} IS NULL OR jsonb_typeof({document}) = 'object'", + name=document, + ) + for document in ("spm_config", "spm_provenance", "bundle") + ), ForeignKeyConstraint( ["simulation_run_id"], [simulation_runs.c.id], @@ -56,12 +77,22 @@ class HouseholdOutput(BaseModel): - """A computed household and the version of its document's schema.""" + """A computed household, the version of its document's schema, and its notes. + + ``warnings`` lists, in order, what the calculation reported about its + inputs; ``spm_config``, ``spm_provenance`` and ``bundle`` are the applied + SPM selection, the SPM receipt and the PolicyEngine.py bundle, each None + when it does not apply. + """ model_config = ConfigDict(extra="forbid", frozen=True) schema_version: int = Field(ge=1) household: dict[str, JsonValue] + warnings: tuple[str, ...] = () + spm_config: dict[str, JsonValue] | None = None + spm_provenance: dict[str, JsonValue] | None = None + bundle: dict[str, JsonValue] | None = None def write_household_output( @@ -76,7 +107,15 @@ def write_household_output( msg = "only a household simulation run has a computed household" raise ValueError(msg) try: - json.dumps(output.household, allow_nan=False) + json.dumps( + [ + output.household, + output.spm_config, + output.spm_provenance, + output.bundle, + ], + allow_nan=False, + ) except ValueError: msg = "a computed household may not hold NaN or an infinity" raise ValueError(msg) from None @@ -85,6 +124,10 @@ def write_household_output( simulation_run_id=run.id, household=output.household, schema_version=output.schema_version, + warnings=list(output.warnings), + spm_config=output.spm_config, + spm_provenance=output.spm_provenance, + bundle=output.bundle, ) ) @@ -106,5 +149,10 @@ def read_household_output( msg = f"simulation run {simulation_run_id} has no computed household" raise LookupError(msg) return HouseholdOutput( - schema_version=row["schema_version"], household=row["household"] + schema_version=row["schema_version"], + household=row["household"], + warnings=tuple(row["warnings"]), + spm_config=row["spm_config"], + spm_provenance=row["spm_provenance"], + bundle=row["bundle"], ) diff --git a/libs/policyengine-simulation-persistence/tests/test_household_results.py b/libs/policyengine-simulation-persistence/tests/test_household_results.py index 051d74e3a..e7143d55b 100644 --- a/libs/policyengine-simulation-persistence/tests/test_household_results.py +++ b/libs/policyengine-simulation-persistence/tests/test_household_results.py @@ -113,3 +113,81 @@ def test_a_household_output_is_a_json_object_with_a_schema_version() -> None: HouseholdOutput(schema_version=0, household={}) with pytest.raises(ValueError, match="household"): HouseholdOutput.model_validate({"schema_version": 1, "household": ["people"]}) + + +SPM_CONFIG = { + "forecast_scenario": "central", + "geography": "national", + "information_date": "2026-01-01", +} +SPM_PROVENANCE = { + "status": "certified", + "forecast": {"release": "2026-09", "years": [2026]}, +} +BUNDLE = { + "policyengine_version": "6.10.0", + "core_package_version": "3.20.0", + "country_package_name": "policyengine-us", + "country_package_version": "1.450.0", + "bundle_manifest_sha256": "a" * 64, +} + + +def test_a_household_is_stored_with_its_warnings_spm_choice_and_bundle( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + runs = _running_household_pair(stores, migrated_database, public_api_rows) + output = _sample().model_copy( + update={ + "warnings": ("The input age_group is deprecated and was ignored.",), + "spm_config": SPM_CONFIG, + "spm_provenance": SPM_PROVENANCE, + "bundle": BUNDLE, + } + ) + + def write_both(connection, succeeded_runs) -> None: + for run in succeeded_runs: + write_household_output(connection, run, output) + + assert stores.transitions.complete_simulation_call("fc-household", write_both) + + with stores.transitions.engine.connect() as connection: + stored = read_household_output(connection, runs[0].id) + assert stored == output + assert stored.warnings == ("The input age_group is deprecated and was ignored.",) + assert stored.spm_config == SPM_CONFIG + assert stored.spm_provenance == SPM_PROVENANCE + assert stored.bundle == BUNDLE + + +def test_a_household_without_warnings_or_spm_reads_back_without_them( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + runs = _running_household_pair(stores, migrated_database, public_api_rows) + + def write_both(connection, succeeded_runs) -> None: + for run in succeeded_runs: + write_household_output(connection, run, _sample()) + + stores.transitions.complete_simulation_call("fc-household", write_both) + + with stores.transitions.engine.connect() as connection: + stored = read_household_output(connection, runs[1].id) + assert stored.warnings == () + assert stored.spm_config is None + assert stored.spm_provenance is None + assert stored.bundle is None + + +def test_household_output_documents_must_be_json_objects() -> None: + with pytest.raises(ValueError, match=r"spm_config\n.*valid dictionary"): + HouseholdOutput.model_validate( + {"schema_version": 1, "household": {}, "spm_config": ["central"]} + ) + with pytest.raises(ValueError, match=r"warnings\.0\n.*valid string"): + HouseholdOutput.model_validate( + {"schema_version": 1, "household": {}, "warnings": [{"text": "x"}]} + ) diff --git a/migrations/versions/0005_household_calculations.py b/migrations/versions/0005_household_calculations.py index 3db8647a9..b41c97ebd 100644 --- a/migrations/versions/0005_household_calculations.py +++ b/migrations/versions/0005_household_calculations.py @@ -2,7 +2,10 @@ A simulation gains ``options``: whatever else changes what it calculates, such as a household's axes, so a run is reused only for the same options. -Existing simulations get empty options. +Existing simulations get empty options. A computed household gains its +warnings (an empty list by default) and three documents, each null when it +does not apply: the SPM selection the calculation applied, its SPM receipt, +and the PolicyEngine.py bundle it ran with. Revision ID: 0005 Revises: 0004 @@ -15,6 +18,8 @@ from alembic import op from sqlalchemy.dialects import postgresql +HOUSEHOLD_OUTPUT_DOCUMENTS = ("spm_config", "spm_provenance", "bundle") + revision = "0005" down_revision = "0004" branch_labels = None @@ -22,7 +27,7 @@ def upgrade() -> None: - """Add the simulation options.""" + """Add the simulation options and the computed household's notes.""" op.add_column( "simulations", sa.Column( @@ -39,10 +44,46 @@ def upgrade() -> None: "jsonb_typeof(options) = 'object'", schema="simulation", ) + op.add_column( + "household_outputs", + sa.Column( + "warnings", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'[]'::jsonb"), + nullable=False, + ), + schema="simulation", + ) + op.create_check_constraint( + op.f("ck_household_outputs_warnings"), + "household_outputs", + "jsonb_typeof(warnings) = 'array'", + schema="simulation", + ) + for document in HOUSEHOLD_OUTPUT_DOCUMENTS: + op.add_column( + "household_outputs", + sa.Column(document, postgresql.JSONB(astext_type=sa.Text()), nullable=True), + schema="simulation", + ) + op.create_check_constraint( + op.f(f"ck_household_outputs_{document}"), + "household_outputs", + f"{document} IS NULL OR jsonb_typeof({document}) = 'object'", + schema="simulation", + ) def downgrade() -> None: """Drop what ``upgrade`` added.""" + for document in (*HOUSEHOLD_OUTPUT_DOCUMENTS, "warnings"): + op.drop_constraint( + op.f(f"ck_household_outputs_{document}"), + "household_outputs", + schema="simulation", + type_="check", + ) + op.drop_column("household_outputs", document, schema="simulation") op.drop_constraint( op.f("ck_simulations_options"), "simulations", From 445cf8253c0cea72c486e3ea2bfa36e3494df992 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:26:04 +0400 Subject: [PATCH 097/124] Record a failure's detail document with the run it failed RunError gains an optional detail, a JSON object stored with the failure (error_detail on a simulation run, comparison_error_detail on a comparison step) for the run's reader, such as the inputs a household calculation rejected. Its message still carries no user data, and the detail is never logged. Only a failed run or step may hold one (revision 0005). Co-Authored-By: Claude Opus 5.5 --- .../README.md | 5 + .../run_transitions.py | 24 ++- .../runs.py | 11 ++ .../tests/test_run_error_details.py | 179 ++++++++++++++++++ .../versions/0005_household_calculations.py | 36 +++- 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/tests/test_run_error_details.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index bf1b05644..9032dde62 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -63,6 +63,11 @@ and takes the catalog version and year from them; a report's status is its newest report run's. The runtime role may read, insert and update runs but not delete them. +A failure records its error code, a message that carries no user data, +and optionally a detail document (`error_detail`, or +`comparison_error_detail` for a comparison step), such as the inputs a +household calculation rejected; only a failed run or step has one. + `RunTransitions` makes every status change one conditional update on the expected status, so a second writer changes nothing: only the caller that moves runs from `pending` to `dispatching` starts their Modal call, the runs diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index 9d83327f2..a6a091f87 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -25,9 +25,10 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Mapping from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from sqlalchemy import and_, exists, func, or_, select, update @@ -76,10 +77,26 @@ class RunErrorCode(StrEnum): @dataclass(frozen=True) class RunError: - """A failure to record on a run; the message must carry no user data.""" + """A failure to record on a run; the message must carry no user data. + + ``detail`` is an optional JSON object stored with the failure for the + run's reader, such as the inputs a household calculation rejected. It may + hold the caller's own inputs, so it is never logged. + """ code: RunErrorCode message: str + detail: Mapping[str, Any] | None = None + + def __post_init__(self) -> None: + """Reject a detail that is not a JSON object.""" + if self.detail is not None and not isinstance(self.detail, Mapping): + msg = "detail must be a JSON object" + raise TypeError(msg) + + def stored_detail(self) -> dict[str, Any] | None: + """Return the detail as the plain dictionary the run tables store.""" + return None if self.detail is None else dict(self.detail) @dataclass(frozen=True) @@ -184,6 +201,7 @@ def record_simulation_dispatch_failure( "status": RunStatus.FAILED.value, "error_code": error.code.value, "error_message": bounded_error_message(error.message), + "error_detail": error.stored_detail(), "completed_at": self._clock(), }, ) @@ -318,6 +336,7 @@ def fail_simulation_call(self, modal_call_id: str, error: RunError) -> bool: "status": RunStatus.FAILED.value, "error_code": error.code.value, "error_message": bounded_error_message(error.message), + "error_detail": error.stored_detail(), "completed_at": self._clock(), "next_check_at": None, }, @@ -518,6 +537,7 @@ def _comparison_failure(self, error: RunError) -> dict[str, object]: "comparison_status": RunStatus.FAILED.value, "comparison_error_code": error.code.value, "comparison_error_message": bounded_error_message(error.message), + "comparison_error_detail": error.stored_detail(), "comparison_completed_at": self._clock(), "comparison_next_check_at": None, } diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py index 39c5e85f0..54e40800e 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -133,6 +133,12 @@ def _status_checks(prefix: str, *, optional: bool) -> tuple[CheckConstraint, ... f"{unless_absent}({status} = 'failed') = ({prefix}error_code IS NOT NULL)", name=f"{prefix}error", ), + # Only a failure carries a detail document, and it is a JSON object. + CheckConstraint( + f"{prefix}error_detail IS NULL OR ({status} = 'failed' " + f"AND jsonb_typeof({prefix}error_detail) = 'object')", + name=f"{prefix}error_detail", + ), # Only a running call is checked, and every running call is due a check. CheckConstraint( f"{unless_absent}" @@ -150,6 +156,9 @@ def _execution_columns(prefix: str, *, required: bool) -> list[Column[Any]]: Column(f"{prefix}modal_call_id", Text, nullable=True), Column(f"{prefix}error_code", Text, nullable=True), Column(f"{prefix}error_message", Text, nullable=True), + # What the failure reported beyond its code and message, such as a + # household calculation's rejected inputs; null for most failures. + Column(f"{prefix}error_detail", JSONB(none_as_null=True), nullable=True), Column( f"{prefix}check_count", Integer, @@ -432,6 +441,7 @@ class SimulationRunRecord: modal_call_id: str | None error_code: str | None error_message: str | None + error_detail: dict[str, Any] | None check_count: int dispatched_at: datetime | None next_check_at: datetime | None @@ -474,6 +484,7 @@ class ReportRunRecord: comparison_modal_call_id: str | None comparison_error_code: str | None comparison_error_message: str | None + comparison_error_detail: dict[str, Any] | None comparison_check_count: int comparison_dispatched_at: datetime | None comparison_next_check_at: datetime | None diff --git a/libs/policyengine-simulation-persistence/tests/test_run_error_details.py b/libs/policyengine-simulation-persistence/tests/test_run_error_details.py new file mode 100644 index 000000000..82c1e8127 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_run_error_details.py @@ -0,0 +1,179 @@ +"""A failure may carry a detail document, stored with the run it failed.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_persistence import ( + NewReportRun, + RunError, + RunErrorCode, + RunStatus, + RunTrigger, +) +from public_api_support import superuser_engine +from report_support import economy_pair, economy_report, household_baseline +from report_support import report_store as definition_report_store +from run_support import economy_run, household_run +from simulation_support import household_simulation, simulation_store +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from transition_support import clocked_stores + +pytestmark = pytest.mark.postgres + +INPUT_ERRORS = { + "code": "invalid_inputs", + "message": "Unrecognized inputs: employment_incme", + "input_errors": [ + { + "type": "household_variable", + "name": "employment_incme", + "path": "people/you/employment_incme", + "message": "employment_incme is not a variable", + } + ], +} +REJECTED = RunError( + RunErrorCode.WORKER_ERROR, + "The household calculation failed: invalid_inputs.", + detail=INPUT_ERRORS, +) + + +def _running_household_pair(stores, database, rows, call_id="fc-household"): + baseline = household_baseline(database, rows) + reform = simulation_store(database).create( + household_simulation(rows, policy_id=rows.us_policy_id) + ) + run_ids = [ + stores.runs.start_simulation_run(household_run(simulation, rows)).run.id + for simulation in (baseline, reform) + ] + assert stores.transitions.claim_simulation_dispatch(run_ids) + assert stores.transitions.record_simulation_dispatched( + run_ids, modal_call_id=call_id, first_check_at=stores.clock() + ) + return run_ids + + +def test_a_failed_call_records_its_detail_on_every_run_it_computes( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run_ids = _running_household_pair(stores, migrated_database, public_api_rows) + + assert stores.transitions.fail_simulation_call("fc-household", REJECTED) + + for run_id in run_ids: + failed = stores.runs.get_simulation_run(run_id) + assert failed.status is RunStatus.FAILED + assert failed.error_message == REJECTED.message + assert failed.error_detail == INPUT_ERRORS + + +def test_a_failure_without_detail_records_none( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run_ids = _running_household_pair(stores, migrated_database, public_api_rows) + + assert stores.transitions.fail_simulation_call( + "fc-household", RunError(RunErrorCode.WORKER_TIMEOUT, "The call timed out.") + ) + + assert stores.runs.get_simulation_run(run_ids[0]).error_detail is None + + +def test_a_refused_dispatch_records_its_detail( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = stores.runs.start_simulation_run( + household_run( + household_baseline(migrated_database, public_api_rows), public_api_rows + ) + ).run + stores.transitions.claim_simulation_dispatch([run.id]) + refused = RunError( + RunErrorCode.DISPATCH_FAILED, + "Starting the Modal call raised NotFoundError.", + detail={"modal_function": "calculate_household_us"}, + ) + + assert stores.transitions.record_simulation_dispatch_failure([run.id], refused) + + assert stores.runs.get_simulation_run(run.id).error_detail == { + "modal_function": "calculate_household_us" + } + + +def test_a_failed_comparison_records_its_detail( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + baseline, reform = economy_pair(migrated_database, public_api_rows) + report = definition_report_store(migrated_database).create( + economy_report(baseline, reform) + ) + runs = [ + stores.runs.start_simulation_run(economy_run(simulation, public_api_rows)).run + for simulation in (baseline, reform) + ] + report_run = stores.runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=runs[0].id, + reform_simulation_run_id=runs[1].id, + trigger=RunTrigger.INITIAL, + ) + ) + for run in runs: + call_id = f"fc-{run.id.hex[:8]}" + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=call_id, first_check_at=stores.clock() + ) + stores.transitions.complete_simulation_call(call_id, lambda *_: None) + stores.transitions.claim_comparison_dispatch(report_run.id) + stores.transitions.record_comparison_dispatched( + report_run.id, modal_call_id="fc-comparison", first_check_at=stores.clock() + ) + + assert stores.transitions.fail_comparison( + report_run.id, + RunError( + RunErrorCode.WORKER_ERROR, + "The Modal call raised ReportExecutionError.", + detail={"code": "missing_parameter"}, + ), + ) + + failed = stores.runs.get_report_run(report_run.id) + assert failed.comparison_error_detail == {"code": "missing_parameter"} + + +def test_the_database_keeps_details_for_failed_runs_only( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run_ids = _running_household_pair(stores, migrated_database, public_api_rows) + engine = superuser_engine(migrated_database.superuser_url) + try: + with ( + pytest.raises(IntegrityError, match="ck_simulation_runs_error_detail"), + engine.begin() as connection, + ): + connection.execute( + text( + "UPDATE simulation.simulation_runs " + "SET error_detail = '{}'::jsonb WHERE id = :run_id" + ), + {"run_id": run_ids[0]}, + ) + finally: + engine.dispose() + + +def test_an_error_detail_must_be_a_json_object() -> None: + with pytest.raises(TypeError, match="detail must be a JSON object"): + RunError(RunErrorCode.WORKER_ERROR, "x", detail=["invalid_inputs"]) # type: ignore[arg-type] diff --git a/migrations/versions/0005_household_calculations.py b/migrations/versions/0005_household_calculations.py index b41c97ebd..5ddd03ea1 100644 --- a/migrations/versions/0005_household_calculations.py +++ b/migrations/versions/0005_household_calculations.py @@ -5,7 +5,9 @@ Existing simulations get empty options. A computed household gains its warnings (an empty list by default) and three documents, each null when it does not apply: the SPM selection the calculation applied, its SPM receipt, -and the PolicyEngine.py bundle it ran with. +and the PolicyEngine.py bundle it ran with. A failed simulation run, or +comparison step, may carry a detail document beyond its error code and +message, such as the inputs a household calculation rejected. Revision ID: 0005 Revises: 0004 @@ -19,6 +21,11 @@ from sqlalchemy.dialects import postgresql HOUSEHOLD_OUTPUT_DOCUMENTS = ("spm_config", "spm_provenance", "bundle") +# Each run table's status column and the prefix of its error columns. +ERROR_DETAIL_COLUMNS = ( + ("simulation_runs", "status", ""), + ("report_runs", "comparison_status", "comparison_"), +) revision = "0005" down_revision = "0004" @@ -27,7 +34,7 @@ def upgrade() -> None: - """Add the simulation options and the computed household's notes.""" + """Add the simulation options, the household notes and the error details.""" op.add_column( "simulations", sa.Column( @@ -72,10 +79,35 @@ def upgrade() -> None: f"{document} IS NULL OR jsonb_typeof({document}) = 'object'", schema="simulation", ) + for table, status, prefix in ERROR_DETAIL_COLUMNS: + op.add_column( + table, + sa.Column( + f"{prefix}error_detail", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + schema="simulation", + ) + op.create_check_constraint( + op.f(f"ck_{table}_{prefix}error_detail"), + table, + f"{prefix}error_detail IS NULL OR ({status} = 'failed' " + f"AND jsonb_typeof({prefix}error_detail) = 'object')", + schema="simulation", + ) def downgrade() -> None: """Drop what ``upgrade`` added.""" + for table, _status, prefix in ERROR_DETAIL_COLUMNS: + op.drop_constraint( + op.f(f"ck_{table}_{prefix}error_detail"), + table, + schema="simulation", + type_="check", + ) + op.drop_column(table, f"{prefix}error_detail", schema="simulation") for document in (*HOUSEHOLD_OUTPUT_DOCUMENTS, "warnings"): op.drop_constraint( op.f(f"ck_household_outputs_{document}"), From df5d6693b4f2650de463f60de217d433454b16a9 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:29:59 +0400 Subject: [PATCH 098/124] Record a household function's outcome on its runs Household calls now have a result recorder. Each request names a policy by its run, so each result maps back to the run it computes: a successful outcome stores each run's calculated household with its warnings, SPM receipt, the applied SPM selection and the bundle, in the transaction that marks the runs succeeded. A typed failure, such as inputs the country model does not know, fails the runs with household_calculation_failed; its message names only the failure's code, and the household error document is the runs' error detail. A recorder's validate signals such a failure with CalculationFailedError, which run recording records instead of an invalid result. Co-Authored-By: Claude Opus 5.5 --- .../run_transitions.py | 3 + .../household_recording.py | 130 ++++++++++++ .../run_recording.py | 27 ++- .../simulation_database.py | 14 +- .../tests/household_support.py | 129 ++++++++++++ .../tests/test_household_recording.py | 197 ++++++++++++++++++ 6 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py create mode 100644 projects/policyengine-simulation-entry/tests/household_support.py create mode 100644 projects/policyengine-simulation-entry/tests/test_household_recording.py diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py index a6a091f87..ad7e5f2c2 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -71,6 +71,9 @@ class RunErrorCode(StrEnum): CALL_NOT_FOUND = "call_not_found" # The output failed validation against its result model. INVALID_RESULT = "invalid_result" + # The household function returned a typed failure, such as inputs the + # country model does not know; the run's error detail holds it. + HOUSEHOLD_CALCULATION_FAILED = "household_calculation_failed" # A comparison step whose baseline or reform simulation run failed. SIMULATION_RUN_FAILED = "simulation_run_failed" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py new file mode 100644 index 000000000..48492f855 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py @@ -0,0 +1,130 @@ +"""Record a household function's outcome on the runs its call computes. + +One call computes one household calculation (one simulation run) or a +household report's baseline and reform (two runs sharing the call). The +request names each policy by its run (``policy_key_for_run``), so each +result maps back to its run. + +A ``HouseholdExecutionResult`` stores each run's calculated household, with +its warnings, SPM receipt, the applied SPM selection and the bundle, in the +transaction that marks the runs succeeded. A ``HouseholdExecutionFailure`` +(inputs the country model does not know, an unusable SPM selection, another +bundle, or a failure inside the calculation) fails the runs with +``household_calculation_failed``: its message names only the failure's code, +and the household error document is the run's error detail, which the +calculation's reader returns. + +This is v2 code: the main path's ``run_simulation`` does not use it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionFailure, + HouseholdExecutionResult, + parse_household_execution_outcome, +) +from policyengine_simulation_persistence import ( + HouseholdOutput, + RunError, + RunErrorCode, + write_household_output, +) + +from policyengine_simulation_entry.run_recording import CalculationFailedError + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from policyengine_simulation_contract.household_execution import ( + HouseholdPolicyResult, + ) + from policyengine_simulation_persistence import SimulationRunRecord + from sqlalchemy import Connection + + type OutputWriter = Callable[ + [Connection, SimulationRunRecord, HouseholdOutput], None + ] + + +def policy_key_for_run(run_id: UUID) -> str: + """Return the policy key that names a run's policy in a household request.""" + return f"run-{run_id.hex}" + + +def _household_output( + result: HouseholdExecutionResult, policy_result: HouseholdPolicyResult +) -> HouseholdOutput: + output = result.output + return HouseholdOutput( + schema_version=result.schema_version, + household=policy_result.household.model_dump(mode="json"), + warnings=policy_result.warnings, + spm_config=( + None + if output.spm_config is None + else output.spm_config.model_dump(mode="json") + ), + spm_provenance=( + None + if policy_result.spm_provenance is None + else policy_result.spm_provenance.model_dump(mode="json") + ), + bundle=output.bundle.model_dump(mode="json"), + ) + + +class HouseholdResultRecorder: + """Validate a household function's outcome and write each run's household.""" + + def __init__(self, *, write_output: OutputWriter = write_household_output) -> None: + """Write each run's household with ``write_output``.""" + self._write_output = write_output + + def validate(self, output: object) -> HouseholdExecutionResult: + """Return the successful outcome. + + Raise CalculationFailedError for a typed failure, and ValueError for + an output that is neither. + """ + outcome = parse_household_execution_outcome(output) + if isinstance(outcome, HouseholdExecutionFailure): + raise CalculationFailedError( + RunError( + RunErrorCode.HOUSEHOLD_CALCULATION_FAILED, + f"The household calculation failed: {outcome.error.code}.", + detail=outcome.error.model_dump(mode="json"), + ) + ) + return outcome + + def write( + self, + connection: Connection, + runs: tuple[SimulationRunRecord, ...], + result: object, + ) -> None: + """Write each run's household in the transaction that succeeds them. + + Raise ValueError, writing nothing, when the outcome has no result for + one of the runs. + """ + if not isinstance(result, HouseholdExecutionResult): + msg = "a household result recorder writes household results only" + raise TypeError(msg) + by_policy_key = { + policy_result.policy_key: policy_result + for policy_result in result.output.results + } + outputs = [] + for run in runs: + policy_result = by_policy_key.get(policy_key_for_run(run.id)) + if policy_result is None: + msg = f"the household outcome has no result for run {run.id}" + raise ValueError(msg) + outputs.append((run, _household_output(result, policy_result))) + for run, output in outputs: + self._write_output(connection, run, output) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 395f23153..6bd072835 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -10,8 +10,9 @@ 3. asks Modal about each claimed call without waiting, and records what it found with conditional writes: a finished call's result is validated and its result rows are written in the transaction that marks its runs - ``succeeded``; a failed or timed-out call is marked ``failed`` with its - error; a call still running, or finished but of a kind this revision has + ``succeeded``; a failed or timed-out call, or one whose output reports + that its calculation failed, is marked ``failed`` with its error; a call + still running, or finished but of a kind this revision has no recorder for, is checked again once its claim's lease ends. Pre-emption is never a failure: Modal re-runs a pre-empted call under the @@ -85,11 +86,29 @@ def _event_name(prefix: str, outcome: str) -> str: INSPECTION_CONCURRENCY = 3 +class CalculationFailedError(Exception): + """A finished call's output reports that its calculation failed. + + A result recorder's ``validate`` raises it for a valid failure document, + such as a household function's typed failure; the runs are then failed + with ``error``, its detail included, rather than as an invalid result. + """ + + def __init__(self, error: RunError) -> None: + """Keep the failure to record on the runs.""" + super().__init__(error.message) + self.error = error + + class SimulationResultRecorder(Protocol): """Validate one kind of simulation call's output and write its result rows.""" def validate(self, output: object) -> object: - """Return the validated result; raise ValueError when it is invalid.""" + """Return the validated result. + + Raise ValueError when it is invalid, and CalculationFailedError when + it validly reports that the calculation failed. + """ ... def write( @@ -534,6 +553,8 @@ async def _record_result( """Validate a finished call's output and write it with its success.""" try: result = await asyncio.to_thread(steps.validate, output) + except CalculationFailedError as failure: + return await self._fail(handlers, failure.error) except ValueError as error: return await self._fail(handlers, _invalid_result(error)) try: diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index 4401f8b7b..5b5a7b485 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -8,10 +8,14 @@ from policyengine_simulation_persistence import ( RunStore, RunTransitions, + SimulationKind, create_simulation_engine, read_schema_revision, ) +from policyengine_simulation_entry.household_recording import ( + HouseholdResultRecorder, +) from policyengine_simulation_entry.modal_calls import ModalCallInspector from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder @@ -45,15 +49,17 @@ async def read_revision(self) -> str | None: def call_recorder(self) -> RunRecorder: """Return what records a reported call's outcome on its runs. - No kind of run has a result recorder yet (stage 2 registers them), so - a finished call is answered no_result_recorder and left for the - safety check; no route creates runs yet. + Household calls have a result recorder. A finished call of a kind + without one (economy runs and comparisons, until their path registers + them) is answered no_result_recorder and left for the safety check. """ return RunRecorder( transitions=RunTransitions(self.engine), runs=RunStore(self.engine), inspector=ModalCallInspector(), - recorders=ResultRecorders(), + recorders=ResultRecorders( + simulations={SimulationKind.HOUSEHOLD: HouseholdResultRecorder()} + ), ) def dispose(self) -> None: diff --git a/projects/policyengine-simulation-entry/tests/household_support.py b/projects/policyengine-simulation-entry/tests/household_support.py new file mode 100644 index 000000000..21b16cb0c --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/household_support.py @@ -0,0 +1,129 @@ +"""Household calculation outcomes, as a household function returns them. + +The builders key each policy's result by its run (``policy_key_for_run``), as +the Simulation Entrypoint asks the household function to. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionFailure, + HouseholdExecutionResult, +) +from policyengine_simulation_contract.spm import ( + SPMRuntimeVersions, + SPMSelection, + build_spm_provenance, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run + +if TYPE_CHECKING: + from collections.abc import Sequence + +CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") +BUNDLE = { + "policyengine_version": "6.10.0", + "core_package_version": "3.33.0", + "country_package_name": "policyengine-us", + "country_package_version": "1.450.0", + "bundle_manifest_sha256": "a" * 64, +} +WORKER = { + "application_name": "policyengine-simulation-v2-py6-10-0", + "function_name": "calculate_household_us", +} +SPM_CONFIG = { + "forecast_content_sha256": "c" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +DEPRECATED_INPUT_WARNING = "The input `age_group` is deprecated and was ignored." + + +def spm_receipt() -> dict[str, Any]: + """Return the SPM receipt of a national, central-trend US calculation.""" + return build_spm_provenance( + forecast_id="forecast-2026", + forecast_sha256="c" * 64, + selection=SPMSelection.model_validate(SPM_CONFIG), + years=("2026",), + runtime_versions=SPMRuntimeVersions.model_validate( + { + "policyengine": "6.10.0", + "policyengine-core": "3.33.0", + "policyengine-us": "1.450.0", + "spm-calculator": "0.4.0", + } + ), + ).model_dump(mode="json") + + +def calculated_household(net_income: float) -> dict[str, Any]: + """Return a one-person US household with its net income calculated. + + Its group collection comes before ``people``, as a caller may send it. + """ + return { + "households": { + "your household": { + "members": ["you"], + "household_net_income": {"2026": net_income}, + } + }, + "people": {"you": {"age": {"2026": 40}}}, + } + + +def succeeded_outcome(run_ids: Sequence[UUID]) -> dict[str, Any]: + """Return a successful calculation with one result per run, in order. + + Each run's household has a net income of 1000 times its position plus one. + """ + return HouseholdExecutionResult.model_validate( + { + "calculation_id": CALCULATION_ID, + "output": { + "country": "us", + "bundle": BUNDLE, + "spm_config": SPM_CONFIG, + "results": [ + { + "policy_key": policy_key_for_run(run_id), + "household": calculated_household(1000.0 * (position + 1)), + "warnings": [DEPRECATED_INPUT_WARNING], + "spm_provenance": spm_receipt(), + } + for position, run_id in enumerate(run_ids) + ], + }, + "worker": WORKER, + } + ).model_dump(mode="json") + + +def failed_outcome() -> dict[str, Any]: + """Return a calculation the household function rejected for unknown inputs.""" + return HouseholdExecutionFailure.model_validate( + { + "calculation_id": CALCULATION_ID, + "error": { + "code": "invalid_inputs", + "message": "Unrecognized inputs: employment_incme", + "input_errors": [ + { + "type": "household_variable", + "name": "employment_incme", + "path": "people/you/employment_incme", + "message": "employment_incme is not a variable", + } + ], + }, + "worker": WORKER, + } + ).model_dump(mode="json") diff --git a/projects/policyengine-simulation-entry/tests/test_household_recording.py b/projects/policyengine-simulation-entry/tests/test_household_recording.py new file mode 100644 index 000000000..78da18017 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_recording.py @@ -0,0 +1,197 @@ +"""Recording a household function's outcome on the runs its call computes.""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import pytest +from household_support import ( + BUNDLE, + DEPRECATED_INPUT_WARNING, + SPM_CONFIG, + calculated_household, + failed_outcome, + spm_receipt, + succeeded_outcome, +) +from policyengine_simulation_contract.call_reports import ( + CallEndedReport, + CallReportStatus, +) +from policyengine_simulation_entry.household_recording import ( + HouseholdResultRecorder, + policy_key_for_run, +) +from policyengine_simulation_entry.modal_calls import CallSucceeded +from policyengine_simulation_entry.run_recording import ( + CalculationFailedError, + ResultRecorders, + RunRecorder, +) +from policyengine_simulation_entry.simulation_database import SimulationDatabase +from policyengine_simulation_persistence import ( + RunErrorCode, + RunStatus, + SimulationKind, +) +from run_fakes import FakeInspector, FakeRunRecord, FakeRunTransitions, ManualClock + + +class OutputCapture: + """Record each household output written, by run.""" + + def __init__(self) -> None: + """Start with nothing written.""" + self.written: dict = {} + + def __call__(self, connection, run, output) -> None: + """Keep the output under its run's ID.""" + self.written[run.id] = (connection, output) + + +def household_run(run_id) -> FakeRunRecord: + """Return a running household run record.""" + return FakeRunRecord( + id=run_id, + simulation_kind=SimulationKind.HOUSEHOLD, + modal_call_id="fc-household", + check_count=0, + ) + + +def test_each_run_gets_its_own_household_with_the_calculations_notes() -> None: + capture = OutputCapture() + recorder = HouseholdResultRecorder(write_output=capture) + baseline_id, reform_id = uuid4(), uuid4() + connection = object() + + result = recorder.validate(succeeded_outcome([baseline_id, reform_id])) + recorder.write( + connection, (household_run(reform_id), household_run(baseline_id)), result + ) + + for run_id, net_income in ((baseline_id, 1000.0), (reform_id, 2000.0)): + written_connection, output = capture.written[run_id] + assert written_connection is connection + assert output.schema_version == 1 + assert output.household == calculated_household(net_income) + assert list(output.household) == ["households", "people"] + assert output.warnings == (DEPRECATED_INPUT_WARNING,) + assert output.spm_config == SPM_CONFIG + assert output.spm_provenance == spm_receipt() + assert output.bundle == BUNDLE + + +def test_a_rejected_calculation_fails_with_the_household_error_as_detail() -> None: + recorder = HouseholdResultRecorder(write_output=OutputCapture()) + + with pytest.raises(CalculationFailedError) as raised: + recorder.validate(failed_outcome()) + + error = raised.value.error + assert error.code is RunErrorCode.HOUSEHOLD_CALCULATION_FAILED + assert error.message == "The household calculation failed: invalid_inputs." + assert "employment_incme" not in error.message + assert error.detail == failed_outcome()["error"] + + +def test_an_output_that_is_no_household_outcome_is_invalid() -> None: + recorder = HouseholdResultRecorder(write_output=OutputCapture()) + + with pytest.raises(ValueError, match="validation error"): + recorder.validate({"status": "succeeded", "output": {}}) + + +def test_nothing_is_written_when_the_output_misses_one_of_the_runs() -> None: + capture = OutputCapture() + recorder = HouseholdResultRecorder(write_output=capture) + covered, missing = uuid4(), uuid4() + result = recorder.validate(succeeded_outcome([covered])) + + with pytest.raises(ValueError, match="no result for run"): + recorder.write( + object(), (household_run(covered), household_run(missing)), result + ) + + assert capture.written == {} + + +def test_a_policy_key_names_its_run() -> None: + run_id = uuid4() + + assert policy_key_for_run(run_id) == f"run-{run_id.hex}" + + +def _recorder(capture: OutputCapture): + clock = ManualClock() + transitions = FakeRunTransitions(clock) + inspector = FakeInspector() + recorder = RunRecorder( + transitions=transitions, + runs=transitions, + inspector=inspector, + recorders=ResultRecorders( + simulations={ + SimulationKind.HOUSEHOLD: HouseholdResultRecorder(write_output=capture) + } + ), + clock=clock, + ) + return recorder, transitions, inspector + + +def test_a_reported_rejection_fails_both_runs_of_a_household_report() -> None: + recorder, transitions, inspector = _recorder(OutputCapture()) + run_ids = transitions.running_call("fc-household", "household", "household") + inspector.outcomes["fc-household"] = CallSucceeded(failed_outcome()) + + status = asyncio.run( + recorder.record_reported_call( + CallEndedReport( + modal_call_id="fc-household", simulation_run_ids=tuple(run_ids) + ) + ) + ) + + assert status is CallReportStatus.RECORDED + for run_id in run_ids: + run = transitions.runs[run_id] + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.HOUSEHOLD_CALCULATION_FAILED + assert run.error.detail == failed_outcome()["error"] + + +def test_a_reported_calculation_succeeds_both_runs_with_their_households() -> None: + capture = OutputCapture() + recorder, transitions, inspector = _recorder(capture) + run_ids = transitions.running_call("fc-household", "household", "household") + inspector.outcomes["fc-household"] = CallSucceeded(succeeded_outcome(run_ids)) + + status = asyncio.run( + recorder.record_reported_call( + CallEndedReport( + modal_call_id="fc-household", simulation_run_ids=tuple(run_ids) + ) + ) + ) + + assert status is CallReportStatus.RECORDED + assert {transitions.runs[run_id].status for run_id in run_ids} == { + RunStatus.SUCCEEDED + } + assert set(capture.written) == set(run_ids) + + +def test_the_database_recorder_records_household_calls() -> None: + database = SimulationDatabase.from_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + try: + recorders = database.call_recorder().recorders + finally: + database.dispose() + + assert isinstance( + recorders.simulations[SimulationKind.HOUSEHOLD], HouseholdResultRecorder + ) From c82152a80a08bcb3f45bf29a4dbed9c1721c93d9 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:34:11 +0400 Subject: [PATCH 099/124] Read a household calculation with its run, simulation and household HouseholdCalculationStore starts or reuses a calculation's run, as RunStore does, and read_calculation returns it as its reader sees it: the run's status, timings, error and error detail, its simulation, and, once it succeeded, the computed household. An economy run reads as none. The tests include a calculation left dispatching: it is failed as abandoned after ten minutes and submitting it again starts its second attempt. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 5 +- .../__init__.py | 2 + .../household_calculations.py | 93 ++++++++++- .../tests/test_household_calculations.py | 145 +++++++++++++++++- 4 files changed, 241 insertions(+), 4 deletions(-) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 9032dde62..591337576 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -39,7 +39,10 @@ options (compared by value), or creates it. The Public API's households and policies never change once stored, so a calculation submitted again finds the same simulation, and `RunStore.start_simulation_run` reuses its run. Two racing submissions may each create the simulation; later ones find the -older row. +older row. `start_run` starts or reuses its run, as `RunStore` does, and +`read_calculation` returns the run (status, timings, error and error +detail), its simulation and, once it succeeded, the computed household; an +economy run is not a household calculation and reads as none. ### Runs diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 8ba43f9e0..574f6ba08 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -20,6 +20,7 @@ read_schema_revision, ) from policyengine_simulation_persistence.household_calculations import ( + HouseholdCalculation, HouseholdCalculationStore, ) from policyengine_simulation_persistence.household_results import ( @@ -88,6 +89,7 @@ "EconomyComparisonRecorder", "EconomyResultLabels", "GeographyLabel", + "HouseholdCalculation", "HouseholdCalculationStore", "HouseholdOutput", "NewReport", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py index 4af4cdce9..51cc31968 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py @@ -10,15 +10,30 @@ Two submissions racing may each create the simulation; both rows describe the same calculation, and later submissions find the older one. That costs one calculation and no wrong result, so no unique constraint is needed. + +``read_calculation`` returns a calculation as its reader sees it: the run's +status, timings and error (with its detail), its simulation, and, once it +succeeded, the computed household. """ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING from sqlalchemy import cast, select from sqlalchemy.dialects.postgresql import JSONB +from policyengine_simulation_persistence.household_results import ( + HouseholdOutput, + read_household_output, +) +from policyengine_simulation_persistence.run_store import RunStore, utc_now +from policyengine_simulation_persistence.runs import ( + RunStatus, + SimulationRunRecord, + simulation_runs, +) from policyengine_simulation_persistence.simulations import ( SimulationKind, SimulationRecord, @@ -27,18 +42,44 @@ ) if TYPE_CHECKING: + from collections.abc import Callable + from datetime import datetime + from uuid import UUID + from sqlalchemy import Engine + from policyengine_simulation_persistence.runs import ( + NewSimulationRun, + StartedSimulationRun, + ) from policyengine_simulation_persistence.simulations import NewSimulation +@dataclass(frozen=True) +class HouseholdCalculation: + """A household calculation: its run, its simulation, and its household. + + ``output`` is set once the run succeeded. + """ + + run: SimulationRunRecord + simulation: SimulationRecord + output: HouseholdOutput | None + + class HouseholdCalculationStore: """Typed SQLAlchemy Core reads and inserts for household calculations.""" - def __init__(self, engine: Engine) -> None: - """Use ``engine``, which connects as the runtime role.""" + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime] = utc_now, + ) -> None: + """Use ``engine`` (the runtime role) and ``clock`` for run timestamps.""" self.engine = engine self._simulations = SimulationStore(engine) + self._runs = RunStore(engine, clock=clock) def find_or_create_simulation( self, new_simulation: NewSimulation @@ -77,3 +118,51 @@ def find_or_create_simulation( if row is not None: return SimulationRecord.from_row(row) return self._simulations.create(new_simulation) + + def start_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Return the reusable run for this setup, or create the next attempt. + + As ``RunStore.start_simulation_run``: an unfinished run, or one that + succeeded within the reuse window, is reused; a failed one never is. + """ + return self._runs.start_simulation_run(new_run, force=force) + + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation run ``run_id``, or None. + + An economy run is not a household calculation, so it reads as None. + """ + with self.engine.connect() as connection: + row = ( + connection.execute( + select(simulation_runs).where( + simulation_runs.c.id == run_id, + simulation_runs.c.simulation_kind + == SimulationKind.HOUSEHOLD.value, + ) + ) + .mappings() + .first() + ) + if row is None: + return None + run = SimulationRunRecord.from_row(row) + simulation_row = ( + connection.execute( + select(simulations).where(simulations.c.id == run.simulation_id) + ) + .mappings() + .one() + ) + output = ( + read_household_output(connection, run.id) + if run.status is RunStatus.SUCCEEDED + else None + ) + return HouseholdCalculation( + run=run, + simulation=SimulationRecord.from_row(simulation_row), + output=output, + ) diff --git a/libs/policyengine-simulation-persistence/tests/test_household_calculations.py b/libs/policyengine-simulation-persistence/tests/test_household_calculations.py index fc00bdc2b..80b2b4cb4 100644 --- a/libs/policyengine-simulation-persistence/tests/test_household_calculations.py +++ b/libs/policyengine-simulation-persistence/tests/test_household_calculations.py @@ -1,13 +1,22 @@ -"""Household simulations are found by what they calculate, or created once.""" +"""Household calculations: simulations found by what they calculate, and read back.""" from __future__ import annotations +from datetime import timedelta +from uuid import uuid4 + import pytest from policyengine_simulation_persistence import ( HouseholdCalculationStore, + HouseholdOutput, + RunError, + RunErrorCode, + RunStatus, create_simulation_engine, + write_household_output, ) from public_api_support import superuser_engine +from run_support import economy_run, household_run from simulation_support import ( economy_simulation, household_simulation, @@ -15,6 +24,7 @@ ) from sqlalchemy import text from sqlalchemy.exc import IntegrityError +from transition_support import ManualClock, clocked_stores pytestmark = pytest.mark.postgres @@ -134,3 +144,136 @@ def test_the_database_refuses_options_that_are_not_an_object( ) finally: engine.dispose() + + +def clocked_household_store(database, clock: ManualClock) -> HouseholdCalculationStore: + """Return a household calculation store whose runs use ``clock``.""" + return HouseholdCalculationStore( + create_simulation_engine(database.runtime_url), clock=clock + ) + + +def _calculation_run(store, rows, **overrides): + simulation = store.find_or_create_simulation(household_simulation(rows)) + return store.start_run(household_run(simulation, rows, **overrides)) + + +def test_a_submitted_calculation_reads_back_pending_without_output( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + started = _calculation_run(store, public_api_rows) + + calculation = store.read_calculation(started.run.id) + + assert started.created + assert calculation.run.id == started.run.id + assert calculation.run.status is RunStatus.PENDING + assert calculation.simulation.household_id == public_api_rows.us_household_id + assert calculation.output is None + + +def test_a_calculation_submitted_again_reuses_its_unfinished_run( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + first = _calculation_run(store, public_api_rows) + + again = _calculation_run(store, public_api_rows) + + assert again.run.id == first.run.id + assert not again.created + + +def test_a_succeeded_calculation_reads_back_with_its_household( + migrated_database, public_api_rows +) -> None: + clock = ManualClock() + store = clocked_household_store(migrated_database, clock) + transitions = clocked_stores(migrated_database, clock).transitions + run = _calculation_run(store, public_api_rows).run + transitions.claim_simulation_dispatch([run.id]) + transitions.record_simulation_dispatched( + [run.id], modal_call_id="fc-calculation", first_check_at=clock() + ) + output = HouseholdOutput( + schema_version=1, + household={"people": {"you": {"age": {"2026": 40}}}}, + warnings=("A warning.",), + ) + transitions.complete_simulation_call( + "fc-calculation", + lambda connection, runs: write_household_output(connection, runs[0], output), + ) + + calculation = store.read_calculation(run.id) + + assert calculation.run.status is RunStatus.SUCCEEDED + assert calculation.output == output + + +def test_a_rejected_calculation_reads_back_with_its_error_detail( + migrated_database, public_api_rows +) -> None: + clock = ManualClock() + store = clocked_household_store(migrated_database, clock) + transitions = clocked_stores(migrated_database, clock).transitions + run = _calculation_run(store, public_api_rows).run + transitions.claim_simulation_dispatch([run.id]) + transitions.record_simulation_dispatched( + [run.id], modal_call_id="fc-rejected", first_check_at=clock() + ) + transitions.fail_simulation_call( + "fc-rejected", + RunError( + RunErrorCode.HOUSEHOLD_CALCULATION_FAILED, + "The household calculation failed: invalid_inputs.", + detail={"code": "invalid_inputs", "message": "Unrecognized inputs"}, + ), + ) + + calculation = store.read_calculation(run.id) + + assert calculation.run.status is RunStatus.FAILED + assert calculation.run.error_code == "household_calculation_failed" + assert calculation.run.error_detail == { + "code": "invalid_inputs", + "message": "Unrecognized inputs", + } + assert calculation.output is None + + +def test_a_calculation_left_dispatching_fails_and_can_be_submitted_again( + migrated_database, public_api_rows +) -> None: + """The instance that claimed the run stopped before recording its call.""" + clock = ManualClock() + store = clocked_household_store(migrated_database, clock) + transitions = clocked_stores(migrated_database, clock).transitions + run = _calculation_run(store, public_api_rows).run + assert transitions.claim_simulation_dispatch([run.id]) + + clock.advance(timedelta(minutes=11)) + transitions.fail_abandoned_dispatches( + claimed_before=clock() - timedelta(minutes=10) + ) + + abandoned = store.read_calculation(run.id) + assert abandoned.run.status is RunStatus.FAILED + assert abandoned.run.error_code == "dispatch_abandoned" + resubmitted = _calculation_run(store, public_api_rows) + assert resubmitted.created + assert resubmitted.run.attempt == 2 + + +def test_only_a_household_run_reads_as_a_calculation( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + economy = simulation_store(migrated_database).create( + economy_simulation(public_api_rows) + ) + economy_run_record = store.start_run(economy_run(economy, public_api_rows)).run + + assert store.read_calculation(economy_run_record.id) is None + assert store.read_calculation(uuid4()) is None From 317806d8d4c872d640d9d8d5510c497315dd40e4 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:46:17 +0400 Subject: [PATCH 100/124] Submit and poll household calculations on the Simulation Entrypoint POST /v2/household-calculations records a household calculation (one household under one policy, with its options, for a catalog version and year) and starts its Modal call; GET /v2/household-calculations/{id} returns its status and, once it ended, its household with its warnings, the applied SPM selection, the SPM receipt and the bundle, or its error. The Public API sends the household in the situation shape with its saved SPM choice and the policy's parameter values; the database checks the household, policy and catalog version exist. The calculation finds or creates its simulation and reuses its run while unfinished or once it succeeded, unless forced. The household function and bundle come from the v2 version manifest for the calculation's PolicyEngine.py version, before anything is written, and a pending run is dispatched by whichever submitter claims it. A version or country without a household function, or an unknown household, policy or catalog version, gets 400; an unreachable manifest or database, or a service without the database or the v2 Modal environment, gets 503. Tests cover a successful call, a failed Modal call, and a calculation left dispatching, which the safety check fails so it can be submitted again. Co-Authored-By: Claude Opus 5.5 --- .../DEPLOYMENT.md | 6 + .../policyengine-simulation-entry/README.md | 25 ++ .../src/policyengine_simulation_entry/app.py | 28 +- .../policyengine_simulation_entry/config.py | 10 + .../household_api_models.py | 263 ++++++++++++++++++ .../household_calculations.py | 243 ++++++++++++++++ .../household_routes.py | 192 +++++++++++++ .../household_workers.py | 119 ++++++++ .../simulation_database.py | 29 +- .../tests/household_fakes.py | 192 +++++++++++++ .../tests/household_harness.py | 127 +++++++++ .../tests/run_fakes.py | 4 + .../tests/test_household_calculations.py | 228 +++++++++++++++ .../tests/test_household_routes.py | 242 ++++++++++++++++ .../tests/test_household_workers.py | 83 ++++++ .../tests/test_openapi.py | 40 +++ 16 files changed, 1827 insertions(+), 4 deletions(-) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py create mode 100644 projects/policyengine-simulation-entry/tests/household_fakes.py create mode 100644 projects/policyengine-simulation-entry/tests/household_harness.py create mode 100644 projects/policyengine-simulation-entry/tests/test_household_calculations.py create mode 100644 projects/policyengine-simulation-entry/tests/test_household_routes.py create mode 100644 projects/policyengine-simulation-entry/tests/test_household_workers.py diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 65465d6b4..58af704da 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -91,6 +91,12 @@ these requests. `beta` does not promote its candidate yet, so in staging the main URL reaches an older revision until it does (plan item 1.39), and staging calls are recorded by the safety check only once it exists there. +Household calculations (`POST /v2/household-calculations`, see the README) +start the v2 household functions' calls in the same Modal environment, +`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`, with the same token, and read the +v2 version manifest there; they need no other setting. Their outcomes are +recorded like any other call's: on the call's report, or by the safety check. + ## Safety check A call whose report never arrives is recorded by the safety check, the Cloud diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 272f2cbf0..f7c0cabcb 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -25,6 +25,29 @@ They qualify tagged Cloud Run candidates before an environment's stable traffic changes and verify its stable endpoint immediately after promotion, in both staging and production. +## Household calculations + +`POST /v2/household-calculations` records a household calculation, one +household under one policy for a catalog version and year, and starts its +Modal call; `GET /v2/household-calculations/{calculation_id}` returns its +status and, once it succeeded, the calculated household with its warnings, +the SPM selection it applied (`spm_config`), its SPM receipt +(`spm_provenance`) and its bundle, or, once it failed, its error. The Public +API sends the household in the situation shape with its saved SPM choice +and the policy's parameter values, because this service cannot read the +Public API's tables; the database checks that the household, policy and +catalog version exist. + +Each calculation is a simulation run of a household simulation (household, +policy and options such as axes), so the same calculation submitted again +reuses its run while it is unfinished or once it succeeded, unless `force` +is set. The household function comes from the v2 version manifest for the +calculation's PolicyEngine.py version, in the v2 application's Modal +environment (`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`). The function +reports when its call ends, and the outcome is recorded then +(`DEPLOYMENT.md`, "Recording run outcomes"). Without the simulation database +or that environment, the routes answer 503. + ## Caller authentication and permissions Protected routes accept Auth0 RS256 access tokens issued by @@ -38,6 +61,8 @@ Auth0 permission: | `POST /simulate/economy/budget-window` | `simulation:submit` | | `GET /jobs/{job_id}` | `simulation:read` | | `GET /budget-window-jobs/{batch_job_id}` | `simulation:read` | +| `POST /v2/household-calculations` | `simulation:submit` | +| `GET /v2/household-calculations/{calculation_id}` | `simulation:read` | | `POST /internal/stage12/reports` | `stage12:operate` | | `GET /internal/stage12/reports/{evaluation_id}` | `stage12:operate` | diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index 9db5f312c..83d35841d 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -71,6 +71,10 @@ route_template, set_observability_header, ) +from policyengine_simulation_entry.household_routes import ( + HouseholdCalculations, + register_household_calculation_routes, +) from policyengine_simulation_entry.modal_identity import ModalIdentityVerifier from policyengine_simulation_entry.schemas import ( SimulationDatabaseReadiness, @@ -107,16 +111,19 @@ @dataclass(frozen=True) class RunServices: - """The simulation database and the call-ended route's seams, for tests. + """The simulation database and the v2 routes' seams, for tests. ``create_app`` builds each one it is not given: the database from - ``SIMULATION_DATABASE_URL``, the call recorder from that database, and the - Modal identity verifier from the expected Modal caller. + ``SIMULATION_DATABASE_URL``, the call recorder from that database, the + Modal identity verifier from the expected Modal caller, and the household + calculations from the database and the v2 application's Modal + environment. """ database: SimulationDatabaseConnection | None = None call_recorder: CallReportRecorder | None = None modal_identity_verifier: ModalCallerVerifier | None = None + household_calculations: HouseholdCalculations | None = None def _temporary_submission_payload( @@ -597,6 +604,15 @@ def create_app( expected_modal_caller = runtime_settings.expected_modal_caller if modal_identity_verifier is None and expected_modal_caller is not None: modal_identity_verifier = ModalIdentityVerifier(expected_modal_caller) + household_calculations = services.household_calculations + if ( + household_calculations is None + and isinstance(runtime_database, SimulationDatabase) + and runtime_settings.v2_modal_environment + ): + household_calculations = runtime_database.household_calculations( + runtime_settings.v2_modal_environment + ) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -659,6 +675,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: reader=simulation_reader, ) _register_service_routes(app, entry) + register_household_calculation_routes( + app, + household_calculations, + submitter=simulation_submitter, + reader=simulation_reader, + ) register_call_report_route( app, verifier=modal_identity_verifier, recorder=call_recorder ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 6ac11436f..48aac04e4 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -182,6 +182,16 @@ def expected_modal_caller(self) -> ExpectedModalCaller | None: environment_name=self.call_report_modal_environment, ) + @property + def v2_modal_environment(self) -> str: + """The Modal environment of the v2 application, or empty when unset. + + The v2 application's report_call_ended function reports calls from + it, so it is the call-report environment; the household routes start + calls and read the v2 version manifest there. + """ + return self.call_report_modal_environment + @property def stage12_resources_configured(self) -> bool: """Whether the Stage 12 manifest environment, database and bucket are set.""" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py new file mode 100644 index 000000000..0119766f3 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -0,0 +1,263 @@ +"""Request and response models of the household calculation routes. + +The Public API sends what the Simulation Entrypoint cannot read itself: the +household in the situation shape the household functions take, with its +saved SPM choice, and each policy's parameter values. The Simulation +Entrypoint records the calculation against the Public API's IDs (household, +policy, catalog version), whose rows the database checks exist. + +This is v2 code: the main path's ``run_simulation`` does not use it. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Annotated, Any, Self +from uuid import UUID + +from policyengine_simulation_contract.household_execution import ( + MAXIMUM_POLICY_PARAMETERS, + Axes, + HouseholdDocument, + ParameterPath, + ParameterPeriod, + ParameterValue, +) +from policyengine_simulation_contract.stage12_bundle import CountryId +from policyengine_simulation_persistence import ( + NewSimulation, + RunStatus, + RunTrigger, + SimulationKind, +) +from policyengine_simulation_persistence.reports import FIRST_YEAR, LAST_YEAR +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + field_validator, + model_validator, +) + +if TYPE_CHECKING: + from policyengine_simulation_persistence import HouseholdCalculation + +PolicyParameters = Annotated[ + dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], + Field(max_length=MAXIMUM_POLICY_PARAMETERS), +] + + +class StrictRouteModel(BaseModel): + """Base for the household routes' models: no unknown fields, finite numbers.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + +class HouseholdPolicyInput(StrictRouteModel): + """A policy: its Public API ID, or none for current law, and its values. + + ``parameters`` maps each changed parameter path to its values by period + range (``"YYYY-MM-DD.YYYY-MM-DD"``), as in the Public API's policy JSON. + Current law changes no parameters. + """ + + policy_id: UUID | None = None + parameters: PolicyParameters = Field(default_factory=dict) + + @model_validator(mode="after") + def require_a_policy_for_parameters(self) -> Self: + """Refuse parameter values without the policy they belong to.""" + if self.policy_id is None and self.parameters: + msg = "current law (no policy_id) changes no parameters" + raise ValueError(msg) + return self + + +class HouseholdCalculationOptions(StrictRouteModel): + """What else changes what a household calculation computes. + + ``axes`` vary inputs across points, and ``add_missing_variables`` adds + every yearly, monthly and eternal variable the household lacks, as the + Public API's ``/calculate-full`` does. The options are part of what a + calculation is, so a calculation is reused only for the same options. + """ + + axes: Axes = None + add_missing_variables: bool = False + + def stored(self) -> dict[str, Any]: + """Return the options a simulation stores: only those set, as JSON.""" + stored: dict[str, Any] = {} + if self.axes is not None: + stored["axes"] = [ + [axis.model_dump(mode="json") for axis in group] for group in self.axes + ] + if self.add_missing_variables: + stored["add_missing_variables"] = True + return stored + + +class HouseholdCalculationSubmission(StrictRouteModel): + """One household under one policy, to calculate for a catalog version and year. + + ``catalog_version_id`` is the Public API's catalog version and + ``policyengine_version`` the PolicyEngine.py version it was published + from, which picks the household function. ``household`` is the stored + household in the situation shape, without axes (they are an option); + ``spm`` is its saved SPM choice, omitted when it has none, so the + bundle's certified defaults apply. A calculation submitted again reuses + its run while it is unfinished or once it succeeded, unless ``force``. + """ + + country: CountryId + catalog_version_id: UUID + policyengine_version: Annotated[str, Field(min_length=1, max_length=255)] + year: Annotated[int, Field(ge=FIRST_YEAR, le=LAST_YEAR)] + household_id: UUID + household: HouseholdDocument + spm: JsonValue = None + policy: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) + options: HouseholdCalculationOptions = Field( + default_factory=HouseholdCalculationOptions + ) + force: bool = False + trigger: RunTrigger = RunTrigger.INITIAL + + @field_validator("household") + @classmethod + def refuse_axes_in_the_household( + cls, household: HouseholdDocument + ) -> HouseholdDocument: + """Require axes in ``options``, where they are part of the calculation.""" + if household.has_axes: + msg = "send axes in options, not in the household" + raise ValueError(msg) + return household + + @property + def spm_requested(self) -> bool: + """Whether the household has a saved SPM choice to calculate with.""" + return "spm" in self.model_fields_set + + def simulation_definition(self) -> NewSimulation: + """Return the household simulation this calculation runs.""" + return NewSimulation( + kind=SimulationKind.HOUSEHOLD, + country=self.country, + household_id=self.household_id, + policy_id=self.policy.policy_id, + options=self.options.stored(), + ) + + +class HouseholdCalculationResult(StrictRouteModel): + """A succeeded calculation's household, warnings, SPM choice and bundle. + + ``household`` keeps the request's people, groups and axes with each + requested value filled in, in the caller's key order. ``spm_config`` is + the complete SPM selection applied and ``spm_provenance`` its receipt, + both null for a country without SPM; ``bundle`` names the + PolicyEngine.py bundle the calculation ran with. + """ + + household: dict[str, JsonValue] + warnings: list[str] + spm_config: dict[str, JsonValue] | None + spm_provenance: dict[str, JsonValue] | None + bundle: dict[str, JsonValue] | None + + +class HouseholdCalculationFailure(StrictRouteModel): + """Why a calculation failed. + + ``code`` is the run's error code, such as ``household_calculation_failed`` + (the household function refused the inputs; ``detail`` holds its error, + with the Public API's error codes and messages), ``worker_error``, + ``worker_timeout`` or ``dispatch_abandoned`` (submit it again). + """ + + code: str + message: str + detail: dict[str, JsonValue] | None + + +class HouseholdCalculationResponse(StrictRouteModel): + """A household calculation's run: what it calculates, its status and outcome. + + ``calculation_id`` is the simulation run's ID. ``result`` is set once the + status is ``succeeded`` and ``error`` once it is ``failed``. + """ + + calculation_id: UUID + simulation_id: UUID + country: str + household_id: UUID + policy_id: UUID | None + options: dict[str, JsonValue] + catalog_version_id: UUID + year: int + attempt: int + trigger: RunTrigger + status: RunStatus + created_at: datetime + dispatched_at: datetime | None + completed_at: datetime | None + result: HouseholdCalculationResult | None + error: HouseholdCalculationFailure | None + + @classmethod + def from_calculation(cls, calculation: HouseholdCalculation) -> Self: + """Describe a stored household calculation.""" + run, simulation, output = ( + calculation.run, + calculation.simulation, + calculation.output, + ) + # The database requires a household simulation to name its household. + if simulation.household_id is None: # pragma: no cover + msg = "a household calculation's simulation has a household" + raise ValueError(msg) + return cls( + calculation_id=run.id, + simulation_id=simulation.id, + country=simulation.country, + household_id=simulation.household_id, + policy_id=simulation.policy_id, + options=simulation.options, + catalog_version_id=run.tax_benefit_model_version_id, + year=run.year, + attempt=run.attempt, + trigger=run.trigger, + status=run.status, + created_at=run.created_at, + dispatched_at=run.dispatched_at, + completed_at=run.completed_at, + result=( + None + if output is None + else HouseholdCalculationResult( + household=output.household, + warnings=list(output.warnings), + spm_config=output.spm_config, + spm_provenance=output.spm_provenance, + bundle=output.bundle, + ) + ), + error=( + None + if run.status is not RunStatus.FAILED + else HouseholdCalculationFailure( + code=run.error_code or "unknown", + message=run.error_message or "", + detail=run.error_detail, + ) + ), + ) + + +class HouseholdRouteError(BaseModel): + """Why a household route refused or could not serve a request.""" + + detail: str diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py new file mode 100644 index 000000000..a1ba19f8f --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -0,0 +1,243 @@ +"""Submit household calculations, and read them as their Modal calls end. + +A household calculation is one simulation run of a household simulation (a +household under one policy, with its options) for a catalog version and +year. Submitting one: + +1. finds the household function the calculation's PolicyEngine.py version + deployed, in the v2 version manifest, before anything is written; +2. finds or creates the simulation, then reuses its run for that catalog + version and year (while unfinished, or once it succeeded) or starts the + next attempt; the database refuses a household, policy or catalog version + the Public API does not have; +3. starts the run's Modal call if the run is pending, whoever created it, so + a run another submitter left pending runs too. Only the submitter whose + claim moves the run to ``dispatching`` starts the call + (``RunDispatchService``). The household function reports when the call + ends, and sim records its outcome then; the safety check covers a call + that never reports and a run left ``dispatching``. + +Reading returns the run as it stands: status, error and, once it succeeded, +the household. Nothing waits for Modal. + +This is v2 code: the main path's ``run_simulation`` does not use it. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Protocol + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, +) +from policyengine_simulation_persistence import NewSimulationRun, RunStatus +from sqlalchemy.exc import IntegrityError + +from policyengine_simulation_entry.household_recording import policy_key_for_run +from policyengine_simulation_entry.modal_calls import ModalRequest + +if TYPE_CHECKING: + from collections.abc import Collection, Mapping, Sequence + from uuid import UUID + + from policyengine_simulation_contract.household_execution import ( + HouseholdDocument, + ) + from policyengine_simulation_persistence import ( + HouseholdCalculation, + NewSimulation, + SimulationRecord, + StartedSimulationRun, + ) + from pydantic import JsonValue + + from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationOptions, + HouseholdCalculationSubmission, + ) + from policyengine_simulation_entry.household_workers import HouseholdWorker + from policyengine_simulation_entry.run_dispatch import DispatchOutcome + +UNKNOWN_REFERENCE_MESSAGE = ( + "The household, policy or catalog version does not exist for this country." +) + + +class UnknownHouseholdReferenceError(LookupError): + """The household, policy or catalog version does not exist for the country.""" + + +class HouseholdCalculationRecords(Protocol): + """The ``HouseholdCalculationStore`` reads and writes a submission needs.""" + + def find_or_create_simulation( + self, new_simulation: NewSimulation + ) -> SimulationRecord: + """Return the household simulation with these inputs, or create it.""" + ... + + def start_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Reuse the run for this setup, or create the next attempt.""" + ... + + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation run ``run_id``, or None.""" + ... + + +class SimulationRunStarter(Protocol): + """Start the one Modal call that computes some runs (``RunDispatchService``).""" + + async def start_simulation_runs( + self, run_ids: Collection[UUID], request: ModalRequest + ) -> DispatchOutcome: + """Claim the pending runs and start their call, once.""" + ... + + +class HouseholdWorkerSource(Protocol): + """Find a country's household function (``ManifestHouseholdWorkers``).""" + + def resolve(self, country: str, policyengine_version: str) -> HouseholdWorker: + """Return the function and bundle, or raise why there is none.""" + ... + + +class HouseholdInputs(Protocol): + """What a household's Modal call calculates, besides its policies.""" + + @property + def country(self) -> Literal["us", "uk"]: + """The household's country.""" + ... + + @property + def household(self) -> HouseholdDocument: + """The household in the situation shape, without axes.""" + ... + + @property + def spm(self) -> JsonValue: + """The household's saved SPM choice, when ``spm_requested``.""" + ... + + @property + def spm_requested(self) -> bool: + """Whether the household has a saved SPM choice.""" + ... + + @property + def options(self) -> HouseholdCalculationOptions: + """The axes and whether missing variables are added.""" + ... + + +def household_modal_request( + worker: HouseholdWorker, + *, + calculation_id: UUID, + inputs: HouseholdInputs, + run_policies: Sequence[tuple[UUID, Mapping[str, object]]], +) -> ModalRequest: + """Return the household function call that computes each run's policy. + + ``run_policies`` pairs each run with its policy's parameter values; the + request names each policy by its run, so the result maps back. The call's + second argument lists the runs, so its last step can report them. + """ + situation = inputs.household.to_situation() + stored_options = inputs.options.stored() + if "axes" in stored_options: + situation["axes"] = stored_options["axes"] + payload: dict[str, object] = { + "calculation_id": calculation_id, + "country": inputs.country, + "household": situation, + "policies": [ + {"policy_key": policy_key_for_run(run_id), "parameters": parameters} + for run_id, parameters in run_policies + ], + "add_missing_variables": inputs.options.add_missing_variables, + # The household's choice is the one it was saved with. + "spm_selection_source": "saved_household", + "bundle": worker.bundle, + } + if inputs.spm_requested: + payload["spm"] = inputs.spm + request = HouseholdExecutionRequest.model_validate(payload) + return ModalRequest( + target=worker.target, + arguments=( + request.model_dump(mode="json"), + [str(run_id) for run_id, _ in run_policies], + ), + ) + + +@dataclass +class HouseholdCalculationService: + """Submit household calculations and read them.""" + + store: HouseholdCalculationRecords + dispatch: SimulationRunStarter + workers: HouseholdWorkerSource + + async def submit( + self, submission: HouseholdCalculationSubmission + ) -> HouseholdCalculation: + """Record the calculation, start its call if needed, and return it. + + Raise HouseholdFunctionUnavailableError or HouseholdWorkersUnavailableError + before anything is written, and UnknownHouseholdReferenceError when + the database refuses its references. + """ + worker = await asyncio.to_thread( + self.workers.resolve, submission.country, submission.policyengine_version + ) + started = await self._start(submission) + run = started.run + if run.status is RunStatus.PENDING: + await self.dispatch.start_simulation_runs( + [run.id], + household_modal_request( + worker, + calculation_id=run.id, + inputs=submission, + run_policies=[(run.id, submission.policy.parameters)], + ), + ) + calculation = await self.read(run.id) + if calculation is None: # pragma: no cover - runs are never deleted + msg = f"household calculation {run.id} disappeared" + raise RuntimeError(msg) + return calculation + + async def _start( + self, submission: HouseholdCalculationSubmission + ) -> StartedSimulationRun: + def start() -> StartedSimulationRun: + simulation = self.store.find_or_create_simulation( + submission.simulation_definition() + ) + return self.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=submission.catalog_version_id, + year=submission.year, + trigger=submission.trigger, + ), + force=submission.force, + ) + + try: + return await asyncio.to_thread(start) + except IntegrityError as error: + raise UnknownHouseholdReferenceError(UNKNOWN_REFERENCE_MESSAGE) from error + + async def read(self, calculation_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation, or None if there is none.""" + return await asyncio.to_thread(self.store.read_calculation, calculation_id) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py new file mode 100644 index 000000000..bb5649ce1 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -0,0 +1,192 @@ +"""The household calculation routes: submit a calculation, then poll it. + +- ``POST /v2/household-calculations`` (permission ``simulation:submit``) + records the calculation and starts its Modal call, answering 202 with the + calculation as it stands. Submitting the same calculation again returns + the same one while it is unfinished or once it succeeded, unless forced. +- ``GET /v2/household-calculations/{calculation_id}`` (``simulation:read``) + returns it: its status, and its household once it succeeded or its error + once it failed. Poll until the status is ``succeeded`` or ``failed``. + +Both answer 503 with ``Retry-After`` when household calculations are not +configured or a dependency cannot be reached. A submission that names a +version or country without a household function, or a household, policy or +catalog version the Public API does not have, gets 400. + +This is v2 code: the main path's ``run_simulation`` does not use it. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + +from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationResponse, + HouseholdCalculationSubmission, + HouseholdRouteError, +) +from policyengine_simulation_entry.household_calculations import ( + UnknownHouseholdReferenceError, +) +from policyengine_simulation_entry.household_workers import ( + HouseholdFunctionUnavailableError, + HouseholdWorkersUnavailableError, +) + +if TYPE_CHECKING: + from fastapi import FastAPI + from policyengine_simulation_persistence import HouseholdCalculation + + from policyengine_simulation_entry.auth import AuthorizationDependency + +logger = logging.getLogger(__name__) + +CALCULATIONS_PATH = "/v2/household-calculations" +RETRY_AFTER_SECONDS = 10 +NOT_CONFIGURED = "Household calculations are not configured." +WORKERS_UNAVAILABLE = "The v2 version manifest could not be read." +DATABASE_UNAVAILABLE = "The simulation database could not be reached." +UNAVAILABLE_RESPONSE = { + "description": ( + "Household calculations are not configured, or a dependency could not " + "be reached; retry after Retry-After seconds" + ), + "model": HouseholdRouteError, +} + + +class HouseholdCalculations(Protocol): + """Submit and read household calculations (``HouseholdCalculationService``).""" + + async def submit( + self, submission: HouseholdCalculationSubmission + ) -> HouseholdCalculation: + """Record the calculation, start its call if needed, and return it.""" + ... + + async def read(self, calculation_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation, or None if there is none.""" + ... + + +def unavailable(detail: str) -> JSONResponse: + """Answer 503 with a wait before retrying.""" + return JSONResponse( + HouseholdRouteError(detail=detail).model_dump(), + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + headers={"Retry-After": str(RETRY_AFTER_SECONDS)}, + ) + + +def refused(detail: str, status_code: int) -> JSONResponse: + """Answer a refusal with its reason.""" + return JSONResponse( + HouseholdRouteError(detail=detail).model_dump(), status_code=status_code + ) + + +def calculation_response( + calculation: HouseholdCalculation, status_code: int +) -> JSONResponse: + """Answer with the calculation as it stands.""" + return JSONResponse( + HouseholdCalculationResponse.from_calculation(calculation).model_dump( + mode="json" + ), + status_code=status_code, + ) + + +def register_household_calculation_routes( + app: FastAPI, + calculations: HouseholdCalculations | None, + *, + submitter: AuthorizationDependency, + reader: AuthorizationDependency, +) -> None: + """Add the household calculation routes; without a service they answer 503.""" + router = APIRouter() + + @router.post( + CALCULATIONS_PATH, + summary="Submit a household calculation", + description=( + "Record a household calculation (a household under one policy, for a " + "catalog version and year) and start it. Poll the returned " + "calculation_id until its status is succeeded or failed." + ), + operation_id="submit_household_calculation", + status_code=status.HTTP_202_ACCEPTED, + response_model=HouseholdCalculationResponse, + responses={ + 400: { + "description": ( + "No household function for this version and country, or an " + "unknown household, policy or catalog version" + ), + "model": HouseholdRouteError, + }, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(submitter)], + ) + async def submit_household_calculation( + body: HouseholdCalculationSubmission, + ) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + try: + calculation = await calculations.submit(body) + except ( + HouseholdFunctionUnavailableError, + UnknownHouseholdReferenceError, + ) as error: + return refused(str(error), status.HTTP_400_BAD_REQUEST) + except HouseholdWorkersUnavailableError: + logger.warning("household_calculation_manifest_unavailable") + return unavailable(WORKERS_UNAVAILABLE) + except SQLAlchemyError as error: + logger.warning( + "household_calculation_database_unavailable", + extra={"error_type": type(error).__name__}, + ) + return unavailable(DATABASE_UNAVAILABLE) + return calculation_response(calculation, status.HTTP_202_ACCEPTED) + + @router.get( + f"{CALCULATIONS_PATH}/{{calculation_id}}", + summary="Get a household calculation", + description=( + "Return the calculation's status, and its household once it " + "succeeded or its error once it failed." + ), + operation_id="get_household_calculation", + response_model=HouseholdCalculationResponse, + responses={ + 404: {"description": "No such calculation", "model": HouseholdRouteError}, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(reader)], + ) + async def get_household_calculation(calculation_id: UUID) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + try: + calculation = await calculations.read(calculation_id) + except SQLAlchemyError as error: + logger.warning( + "household_calculation_database_unavailable", + extra={"error_type": type(error).__name__}, + ) + return unavailable(DATABASE_UNAVAILABLE) + if calculation is None: + return refused("No such household calculation.", status.HTTP_404_NOT_FOUND) + return calculation_response(calculation, status.HTTP_200_OK) + + app.include_router(router) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py new file mode 100644 index 000000000..cae5be73d --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py @@ -0,0 +1,119 @@ +"""Find a country's household function in the v2 version manifest. + +The Public API names each calculation's catalog version and that version's +PolicyEngine.py version. The v2 version manifest (a Modal Dict that each v2 +deployment publishes to) maps the PolicyEngine.py version to its deployed v2 +worker application, which names each country's household function and the +bundle it runs. The calculation's request carries that bundle, and the +household function refuses any other. + +This is v2 code: the main path's ``run_simulation`` does not use it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import modal +from policyengine_simulation_contract.household_execution import ( + HouseholdBundleProvenance, +) +from policyengine_simulation_contract.stage12_manifest import ( + V2_VERSION_MANIFEST_NAME, + V2ManifestLoader, +) + +from policyengine_simulation_entry.modal_calls import OWN_PROCESS_SIGNALS, ModalTarget + +if TYPE_CHECKING: + from policyengine_simulation_contract.stage12_manifest import ( + V2ManifestStore, + V2VersionManifest, + ) + + +class HouseholdFunctionUnavailableError(LookupError): + """The manifest has no household function for this country and version.""" + + +class HouseholdWorkersUnavailableError(RuntimeError): + """The manifest could not be read; ask again later.""" + + +@dataclass(frozen=True) +class HouseholdWorker: + """A country's deployed household function and the bundle it runs.""" + + target: ModalTarget + bundle: HouseholdBundleProvenance + + +class ManifestHouseholdWorkers: + """Resolve household functions from the v2 version manifest. + + Each resolution reads the manifest, which is small, so a newly published + version is found at once. ``V2ManifestLoader`` keeps its last valid copy + for a stored value that does not validate. + """ + + def __init__(self, loader: V2ManifestLoader) -> None: + """Read the manifest through ``loader``.""" + self._loader = loader + + @classmethod + def from_store(cls, store: V2ManifestStore) -> ManifestHouseholdWorkers: + """Read the manifest from ``store``.""" + return cls(V2ManifestLoader(store)) + + @classmethod + def from_modal_environment(cls, environment: str) -> ManifestHouseholdWorkers: + """Read the manifest from its Modal Dict in ``environment``. + + Nothing is read from Modal until the first resolution. + """ + return cls.from_store( + modal.Dict.from_name(V2_VERSION_MANIFEST_NAME, environment_name=environment) + ) + + def _manifest(self) -> V2VersionManifest: + try: + return self._loader.load() + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise + msg = "the v2 version manifest could not be read" + raise HouseholdWorkersUnavailableError(msg) from error + + def resolve(self, country: str, policyengine_version: str) -> HouseholdWorker: + """Return ``country``'s household function in ``policyengine_version``. + + Raise HouseholdFunctionUnavailableError when the manifest has no such + version, or the version no household function for the country, and + HouseholdWorkersUnavailableError when the manifest cannot be read. + """ + worker = self._manifest().versions.get(policyengine_version) + if worker is None: + msg = ( + f"PolicyEngine.py {policyengine_version} has no deployed v2 worker " + "application" + ) + raise HouseholdFunctionUnavailableError(msg) + country_worker = worker.household_worker(country) + if country_worker is None or country_worker.household_callable is None: + msg = ( + f"PolicyEngine.py {policyengine_version} has no household function " + f"for country {country!r}" + ) + raise HouseholdFunctionUnavailableError(msg) + return HouseholdWorker( + target=ModalTarget( + application=worker.application_name, + function=country_worker.household_callable, + ), + bundle=HouseholdBundleProvenance.from_bundle( + worker.bundle, + bundle_manifest_sha256=worker.bundle_manifest_sha256, + country=country, + ), + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index 5b5a7b485..8378bff67 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from policyengine_simulation_persistence import ( + HouseholdCalculationStore, RunStore, RunTransitions, SimulationKind, @@ -13,10 +14,18 @@ read_schema_revision, ) +from policyengine_simulation_entry.household_calculations import ( + HouseholdCalculationService, +) from policyengine_simulation_entry.household_recording import ( HouseholdResultRecorder, ) -from policyengine_simulation_entry.modal_calls import ModalCallInspector +from policyengine_simulation_entry.household_workers import ManifestHouseholdWorkers +from policyengine_simulation_entry.modal_calls import ( + ModalCallDispatcher, + ModalCallInspector, +) +from policyengine_simulation_entry.run_dispatch import RunDispatchService from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder if TYPE_CHECKING: @@ -62,6 +71,24 @@ def call_recorder(self) -> RunRecorder: ), ) + def household_calculations( + self, modal_environment: str + ) -> HouseholdCalculationService: + """Return the household calculation service on this database. + + It finds household functions in the v2 version manifest of + ``modal_environment`` and starts their calls there. Nothing connects + until the first request. + """ + return HouseholdCalculationService( + store=HouseholdCalculationStore(self.engine), + dispatch=RunDispatchService( + transitions=RunTransitions(self.engine), + dispatcher=ModalCallDispatcher(modal_environment), + ), + workers=ManifestHouseholdWorkers.from_modal_environment(modal_environment), + ) + def dispose(self) -> None: """Close every pooled connection.""" self.engine.dispose() diff --git a/projects/policyengine-simulation-entry/tests/household_fakes.py b/projects/policyengine-simulation-entry/tests/household_fakes.py new file mode 100644 index 000000000..f952cdfa4 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/household_fakes.py @@ -0,0 +1,192 @@ +"""In-memory household simulations and calculations, for the household tests. + +``FakeHouseholdStore`` keeps household simulations and run setups and leaves +each run's status to ``FakeRunTransitions``, so the dispatcher, the recorder +and the reader all see one copy of every run. It reuses runs by the same +rules as ``RunStore``: an unfinished run, or one that succeeded, is reused +unless forced; a failed one never is. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from policyengine_simulation_contract.stage12_manifest import publish_v2_manifest +from policyengine_simulation_entry.household_workers import ManifestHouseholdWorkers +from policyengine_simulation_persistence import ( + HouseholdCalculation, + RunStatus, + SimulationKind, + SimulationRecord, + SimulationRunRecord, + StartedSimulationRun, +) +from sqlalchemy.exc import IntegrityError +from stage12_fixtures import worker + +if TYPE_CHECKING: + from policyengine_simulation_persistence import ( + HouseholdOutput, + NewSimulation, + NewSimulationRun, + RunTrigger, + ) + from run_fakes import FakeRunTransitions, ManualClock + +# The PolicyEngine.py version the fixture manifest's worker runs. +POLICYENGINE_VERSION = "5.2.0" +CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-00000000ca7a") + + +@dataclass(frozen=True) +class RunSetup: + """What a fake run runs: its simulation, catalog version, year and attempt.""" + + simulation_id: UUID + catalog_version_id: UUID + year: int + attempt: int + trigger: RunTrigger + + +class FakeHouseholdStore: + """Household simulations and run setups over ``FakeRunTransitions``' runs.""" + + def __init__(self, transitions: FakeRunTransitions, clock: ManualClock) -> None: + """Start with no simulations.""" + self.transitions = transitions + self.clock = clock + self.simulations: dict[UUID, SimulationRecord] = {} + self.setups: dict[UUID, RunSetup] = {} + self.outputs: dict[UUID, HouseholdOutput] = {} + # Households the Public API does not have: creating their simulations + # breaks a foreign key. + self.unknown_households: set[UUID] = set() + + def find_or_create_simulation( + self, new_simulation: NewSimulation + ) -> SimulationRecord: + """Return the household simulation with these inputs, or create it.""" + if new_simulation.household_id in self.unknown_households: + statement = "INSERT INTO simulation.simulations" + raise IntegrityError( + statement, {}, LookupError(new_simulation.household_id) + ) + for simulation in self.simulations.values(): + if ( + simulation.country, + simulation.household_id, + simulation.policy_id, + simulation.dynamic_id, + simulation.options, + ) == ( + new_simulation.country, + new_simulation.household_id, + new_simulation.policy_id, + new_simulation.dynamic_id, + new_simulation.options, + ): + return simulation + created = SimulationRecord( + id=uuid4(), + kind=SimulationKind.HOUSEHOLD, + country=new_simulation.country, + policy_id=new_simulation.policy_id, + dynamic_id=new_simulation.dynamic_id, + household_id=new_simulation.household_id, + region_code=None, + dataset_name=None, + legacy_simulation_id=None, + options=dict(new_simulation.options), + created_at=self.clock(), + ) + self.simulations[created.id] = created + return created + + def start_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Reuse the newest attempt for this setup, or add the next one.""" + attempts = [ + run_id + for run_id, setup in self.setups.items() + if (setup.simulation_id, setup.catalog_version_id, setup.year) + == ( + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + ] + latest = max( + attempts, key=lambda run_id: self.setups[run_id].attempt, default=None + ) + if ( + latest is not None + and not force + and self.transitions.runs[latest].status is not RunStatus.FAILED + ): + return StartedSimulationRun(run=self.run_record(latest), created=False) + run_id = self.transitions.add_run("household", updated_at=self.clock()) + self.setups[run_id] = RunSetup( + simulation_id=new_run.simulation_id, + catalog_version_id=new_run.tax_benefit_model_version_id, + year=new_run.year, + attempt=1 if latest is None else self.setups[latest].attempt + 1, + trigger=new_run.trigger, + ) + return StartedSimulationRun(run=self.run_record(run_id), created=True) + + def run_record(self, run_id: UUID) -> SimulationRunRecord: + """Return the run as the database would, from its fake execution.""" + execution = self.transitions.runs[run_id] + setup = self.setups[run_id] + error = execution.error + finished = execution.status in {RunStatus.SUCCEEDED, RunStatus.FAILED} + return SimulationRunRecord( + id=run_id, + simulation_id=setup.simulation_id, + simulation_kind=SimulationKind.HOUSEHOLD, + tax_benefit_model_version_id=setup.catalog_version_id, + year=setup.year, + attempt=setup.attempt, + region_code=None, + dataset_id=None, + trigger=setup.trigger, + status=execution.status, + modal_call_id=execution.modal_call_id, + error_code=None if error is None else error.code.value, + error_message=None if error is None else error.message, + error_detail=None if error is None else error.stored_detail(), + check_count=execution.check_count, + dispatched_at=None, + next_check_at=execution.next_check_at, + completed_at=self.clock() if finished else None, + created_at=self.clock(), + updated_at=execution.updated_at or self.clock(), + ) + + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation run ``run_id``, or None.""" + if run_id not in self.setups: + return None + run = self.run_record(run_id) + return HouseholdCalculation( + run=run, + simulation=self.simulations[run.simulation_id], + output=( + self.outputs.get(run_id) if run.status is RunStatus.SUCCEEDED else None + ), + ) + + def write_output(self, _connection, run, output: HouseholdOutput) -> None: + """Keep a run's household, as the result writer stores it.""" + self.outputs[run.id] = output + + +def manifest_workers() -> ManifestHouseholdWorkers: + """Return household workers resolved from a manifest of the fixture worker.""" + store: dict[str, object] = {} + publish_v2_manifest(store=store, worker=worker()) + return ManifestHouseholdWorkers.from_store(store) diff --git a/projects/policyengine-simulation-entry/tests/household_harness.py b/projects/policyengine-simulation-entry/tests/household_harness.py new file mode 100644 index 000000000..96eb75c39 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/household_harness.py @@ -0,0 +1,127 @@ +"""A household calculation service over fake runs, Modal and manifest. + +``Harness`` wires the real ``HouseholdCalculationService`` and +``RunRecorder`` to ``FakeHouseholdStore``, ``FakeRunTransitions``, a fake +dispatcher and inspector, and the fixture manifest, all on one clock. +""" + +from __future__ import annotations + +import asyncio +from uuid import UUID + +from household_fakes import ( + CATALOG_VERSION_ID, + POLICYENGINE_VERSION, + FakeHouseholdStore, + manifest_workers, +) +from policyengine_simulation_contract.call_reports import CallEndedReport +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, +) +from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationSubmission, +) +from policyengine_simulation_entry.household_calculations import ( + HouseholdCalculationService, +) +from policyengine_simulation_entry.household_recording import ( + HouseholdResultRecorder, +) +from policyengine_simulation_entry.run_dispatch import RunDispatchService +from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder +from policyengine_simulation_persistence import SimulationKind +from run_fakes import FakeDispatcher, FakeInspector, FakeRunTransitions, ManualClock + +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-0000000000a1") +POLICY_ID = UUID("00000000-0000-4000-8000-0000000000b1") +US_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, + "households": { + "your household": { + "members": ["you"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + } + }, +} +EARNINGS_AXES = [ + [{"name": "employment_income", "count": 5, "min": 0, "max": 80_000}], +] +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 3000} +} +SPM_CHOICE = {"scenario": "ce_trend", "geography_kind": "national"} + + +def submission(**overrides: object) -> HouseholdCalculationSubmission: + """Return a 2026 current-law calculation of a one-person US household.""" + values: dict[str, object] = { + "country": "us", + "catalog_version_id": CATALOG_VERSION_ID, + "policyengine_version": POLICYENGINE_VERSION, + "year": 2026, + "household_id": HOUSEHOLD_ID, + "household": US_HOUSEHOLD, + } + values.update(overrides) + return HouseholdCalculationSubmission.model_validate(values) + + +class Harness: + """A service over fake runs, Modal and manifest, with a recorder for reports.""" + + def __init__(self, dispatcher: FakeDispatcher | None = None) -> None: + """Build every part on one clock.""" + self.clock = ManualClock() + self.transitions = FakeRunTransitions(self.clock) + self.store = FakeHouseholdStore(self.transitions, self.clock) + self.dispatcher = dispatcher or FakeDispatcher() + self.inspector = FakeInspector() + self.service = HouseholdCalculationService( + store=self.store, + dispatch=RunDispatchService( + transitions=self.transitions, + dispatcher=self.dispatcher, + clock=self.clock, + ), + workers=manifest_workers(), + ) + self.recorder = RunRecorder( + transitions=self.transitions, + runs=self.transitions, + inspector=self.inspector, + recorders=ResultRecorders( + simulations={ + SimulationKind.HOUSEHOLD: HouseholdResultRecorder( + write_output=self.store.write_output + ) + } + ), + clock=self.clock, + ) + + def submit(self, **overrides: object): + """Submit a calculation and return it as it reads afterwards.""" + return asyncio.run(self.service.submit(submission(**overrides))) + + def read(self, run_id: UUID): + """Read a calculation.""" + return asyncio.run(self.service.read(run_id)) + + def report_call_ended(self, run_id: UUID) -> None: + """Deliver the call's report that it ended, as report_call_ended does.""" + call_id = self.transitions.runs[run_id].modal_call_id + assert call_id is not None + asyncio.run( + self.recorder.record_reported_call( + CallEndedReport(modal_call_id=call_id, simulation_run_ids=(run_id,)) + ) + ) + + def sent_request(self, position: int = -1) -> HouseholdExecutionRequest: + """Return a spawned call's household request.""" + return HouseholdExecutionRequest.model_validate( + self.dispatcher.requests[position].arguments[0] + ) diff --git a/projects/policyengine-simulation-entry/tests/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py index acee770ed..e8204249b 100644 --- a/projects/policyengine-simulation-entry/tests/run_fakes.py +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -431,6 +431,10 @@ def fail_abandoned_dispatches( and run.updated_at < claimed_before ): run.status = RunStatus.FAILED + run.error = RunError( + RunErrorCode.DISPATCH_ABANDONED, + "No Modal call was recorded for the dispatch in time.", + ) failed_runs.append(run.id) failed_comparisons = [] for comparison in self.comparisons.values(): diff --git a/projects/policyengine-simulation-entry/tests/test_household_calculations.py b/projects/policyengine-simulation-entry/tests/test_household_calculations.py new file mode 100644 index 000000000..ef124f782 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_calculations.py @@ -0,0 +1,228 @@ +"""Submitting household calculations, and reading them as their calls end.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta + +import pytest +from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION +from household_harness import ( + CTC_REFORM, + EARNINGS_AXES, + HOUSEHOLD_ID, + POLICY_ID, + SPM_CHOICE, + US_HOUSEHOLD, + Harness, + submission, +) +from household_support import calculated_household, succeeded_outcome +from policyengine_simulation_contract.stage12_manifest import v2_application_name +from policyengine_simulation_entry.household_calculations import ( + UnknownHouseholdReferenceError, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run +from policyengine_simulation_entry.household_workers import ( + HouseholdFunctionUnavailableError, +) +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallSucceeded, + ModalTarget, +) +from policyengine_simulation_persistence import ( + NewSimulationRun, + RunError, + RunErrorCode, + RunStatus, + RunTrigger, +) +from run_fakes import FakeDispatcher + + +def test_a_submitted_calculation_starts_one_household_call() -> None: + harness = Harness() + + calculation = harness.submit( + spm=SPM_CHOICE, + policy={"policy_id": str(POLICY_ID), "parameters": CTC_REFORM}, + options={"axes": EARNINGS_AXES, "add_missing_variables": True}, + ) + + assert calculation.run.status is RunStatus.RUNNING + assert calculation.simulation.policy_id == POLICY_ID + assert calculation.simulation.options == { + "axes": EARNINGS_AXES, + "add_missing_variables": True, + } + (spawned,) = harness.dispatcher.requests + assert spawned.target == ModalTarget( + application=v2_application_name(POLICYENGINE_VERSION), + function="calculate_household_us", + ) + assert spawned.arguments[1] == [str(calculation.run.id)] + request = harness.sent_request() + assert request.calculation_id == calculation.run.id + assert [policy.policy_key for policy in request.policies] == [ + policy_key_for_run(calculation.run.id) + ] + assert request.policies[0].parameters == CTC_REFORM + situation = request.household.to_situation() + assert situation["people"] == US_HOUSEHOLD["people"] + assert situation["axes"] == EARNINGS_AXES + assert request.add_missing_variables + assert request.spm_requested + assert request.spm == SPM_CHOICE + assert request.spm_selection_source == "saved_household" + assert request.bundle.policyengine_version == POLICYENGINE_VERSION + + +def test_a_household_without_an_spm_choice_inherits_the_defaults() -> None: + harness = Harness() + + calculation = harness.submit() + + assert "spm" not in harness.dispatcher.requests[0].arguments[0] + assert not harness.sent_request().spm_requested + assert calculation.simulation.options == {} + assert calculation.simulation.policy_id is None + + +def test_submitting_again_reuses_the_running_calculation() -> None: + harness = Harness() + + first = harness.submit() + again = harness.submit() + + assert again.run.id == first.run.id + assert len(harness.dispatcher.requests) == 1 + + +def test_a_pending_calculation_left_by_another_submitter_is_dispatched() -> None: + harness = Harness() + simulation = harness.store.find_or_create_simulation( + submission().simulation_definition() + ) + left_pending = harness.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=CATALOG_VERSION_ID, + year=2026, + trigger=RunTrigger.INITIAL, + ) + ).run + + calculation = harness.submit() + + assert calculation.run.id == left_pending.id + assert calculation.run.status is RunStatus.RUNNING + assert len(harness.dispatcher.requests) == 1 + + +def test_an_unsupported_version_writes_nothing() -> None: + harness = Harness() + + with pytest.raises(HouseholdFunctionUnavailableError): + harness.submit(policyengine_version="9.9.9") + + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_an_unknown_household_is_refused_without_a_call() -> None: + harness = Harness() + harness.store.unknown_households.add(HOUSEHOLD_ID) + + with pytest.raises(UnknownHouseholdReferenceError): + harness.submit() + + assert harness.dispatcher.requests == [] + + +def test_a_refused_call_reads_back_failed() -> None: + harness = Harness(FakeDispatcher(failure=RuntimeError("Modal refused"))) + + calculation = harness.submit() + + assert calculation.run.status is RunStatus.FAILED + assert calculation.run.error_code == "dispatch_failed" + + +def test_a_calculation_is_recorded_when_its_call_reports_success() -> None: + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([submitted.run.id]) + ) + + harness.report_call_ended(submitted.run.id) + + calculation = harness.read(submitted.run.id) + assert calculation.run.status is RunStatus.SUCCEEDED + assert calculation.output.household == calculated_household(1000.0) + + +def test_a_succeeded_calculation_runs_again_only_when_forced() -> None: + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([submitted.run.id]) + ) + harness.report_call_ended(submitted.run.id) + + reused = harness.submit() + forced = harness.submit(force=True) + + assert reused.run.id == submitted.run.id + assert forced.run.attempt == 2 + assert forced.run.status is RunStatus.RUNNING + assert len(harness.dispatcher.requests) == 2 + + +def test_a_failed_modal_call_reads_back_failed() -> None: + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallFailed( + RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised MemoryError.") + ) + + harness.report_call_ended(submitted.run.id) + + calculation = harness.read(submitted.run.id) + assert calculation.run.status is RunStatus.FAILED + assert calculation.run.error_code == "worker_error" + assert calculation.output is None + + +class StoppingDispatcher(FakeDispatcher): + """Stop the instance after it claimed the run, before Modal answers.""" + + async def spawn(self, request): + """Record the request, then stop as a stopped instance does.""" + self.requests.append(request) + raise asyncio.CancelledError + + +def test_a_calculation_left_dispatching_fails_and_runs_again() -> None: + harness = Harness(StoppingDispatcher()) + with pytest.raises(asyncio.CancelledError): + harness.submit() + (stopped_run_id,) = harness.store.setups + assert harness.transitions.runs[stopped_run_id].status is RunStatus.DISPATCHING + + harness.clock.advance(timedelta(minutes=11)) + asyncio.run(harness.recorder.record_due_outcomes()) + + abandoned = harness.read(stopped_run_id) + assert abandoned.run.status is RunStatus.FAILED + assert abandoned.run.error_code == "dispatch_abandoned" + harness.dispatcher = FakeDispatcher() + harness.service.dispatch.dispatcher = harness.dispatcher + resubmitted = harness.submit() + assert resubmitted.run.id != stopped_run_id + assert resubmitted.run.attempt == 2 + assert resubmitted.run.status is RunStatus.RUNNING diff --git a/projects/policyengine-simulation-entry/tests/test_household_routes.py b/projects/policyengine-simulation-entry/tests/test_household_routes.py new file mode 100644 index 000000000..38224268a --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_routes.py @@ -0,0 +1,242 @@ +"""The household calculation routes: submit, then poll until the call ends.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from conftest import FakeBackend, make_settings +from fastapi.testclient import TestClient +from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION +from household_harness import ( + EARNINGS_AXES, + HOUSEHOLD_ID, + POLICY_ID, + US_HOUSEHOLD, + Harness, +) +from household_support import ( + BUNDLE, + SPM_CONFIG, + calculated_household, + failed_outcome, + spm_receipt, + succeeded_outcome, +) +from policyengine_simulation_entry.app import RunServices, create_app +from policyengine_simulation_entry.household_calculations import ( + HouseholdCalculationService, +) +from policyengine_simulation_entry.household_workers import ( + HouseholdWorkersUnavailableError, + ManifestHouseholdWorkers, +) +from policyengine_simulation_entry.modal_calls import CallSucceeded +from policyengine_simulation_entry.simulation_database import SimulationDatabase + +SUBMIT_PATH = "/v2/household-calculations" + + +def submission_body(**overrides: object) -> dict: + """Return the JSON body of a 2026 current-law US calculation.""" + body: dict[str, object] = { + "country": "us", + "catalog_version_id": str(CATALOG_VERSION_ID), + "policyengine_version": POLICYENGINE_VERSION, + "year": 2026, + "household_id": str(HOUSEHOLD_ID), + "household": US_HOUSEHOLD, + } + body.update(overrides) + return body + + +@pytest.fixture +def harness() -> Harness: + """Return a household calculation service over fakes.""" + return Harness() + + +@pytest.fixture +def household_client(harness: Harness): + """Return a client of an app serving the harness's household calculations.""" + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + run_services=RunServices(household_calculations=harness.service), + ) + with TestClient(app) as test_client: + yield test_client + + +def test_a_submitted_calculation_is_accepted_and_running(household_client) -> None: + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + assert body["household_id"] == str(HOUSEHOLD_ID) + assert body["policy_id"] is None + assert body["options"] == {} + assert body["catalog_version_id"] == str(CATALOG_VERSION_ID) + assert body["year"] == 2026 + assert body["attempt"] == 1 + assert body["trigger"] == "initial" + assert body["result"] is None + assert body["error"] is None + + +def test_a_succeeded_calculation_reads_back_its_household( + household_client, harness +) -> None: + submitted = household_client.post( + SUBMIT_PATH, + json=submission_body( + policy={"policy_id": str(POLICY_ID), "parameters": {}}, + options={"axes": EARNINGS_AXES}, + ), + ).json() + run_id = submitted["calculation_id"] + stored_id = UUID(run_id) + call_id = harness.transitions.runs[stored_id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(succeeded_outcome([stored_id])) + harness.report_call_ended(stored_id) + + response = household_client.get(f"{SUBMIT_PATH}/{run_id}") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "succeeded" + assert body["policy_id"] == str(POLICY_ID) + assert body["options"] == {"axes": EARNINGS_AXES} + assert body["result"] == { + "household": calculated_household(1000.0), + "warnings": ["The input `age_group` is deprecated and was ignored."], + "spm_config": SPM_CONFIG, + "spm_provenance": spm_receipt(), + "bundle": BUNDLE, + } + assert list(body["result"]["household"]) == ["households", "people"] + + +def test_a_rejected_calculation_reads_back_its_household_error( + household_client, harness +) -> None: + run_id = household_client.post(SUBMIT_PATH, json=submission_body()).json()[ + "calculation_id" + ] + stored_id = UUID(run_id) + call_id = harness.transitions.runs[stored_id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(failed_outcome()) + harness.report_call_ended(stored_id) + + body = household_client.get(f"{SUBMIT_PATH}/{run_id}").json() + + assert body["status"] == "failed" + assert body["result"] is None + assert body["error"] == { + "code": "household_calculation_failed", + "message": "The household calculation failed: invalid_inputs.", + "detail": failed_outcome()["error"], + } + + +def test_an_unknown_calculation_is_not_found(household_client) -> None: + response = household_client.get(f"{SUBMIT_PATH}/{uuid4()}") + + assert response.status_code == 404 + + +@pytest.mark.parametrize( + "overrides", + [ + {"household": {**US_HOUSEHOLD, "axes": EARNINGS_AXES}}, + {"policy": {"parameters": {"gov.irs.credits.ctc.refundable": {}}}}, + {"year": 1899}, + {"country": "ca"}, + ], + ids=["axes-in-household", "parameters-without-policy", "year", "country"], +) +def test_a_malformed_submission_is_refused_before_anything_runs( + household_client, harness, overrides +) -> None: + response = household_client.post(SUBMIT_PATH, json=submission_body(**overrides)) + + assert response.status_code == 422 + assert harness.dispatcher.requests == [] + + +def test_a_version_without_a_household_function_is_refused(household_client) -> None: + response = household_client.post( + SUBMIT_PATH, json=submission_body(policyengine_version="9.9.9") + ) + + assert response.status_code == 400 + assert "9.9.9" in response.json()["detail"] + + +def test_an_unknown_household_is_refused(household_client, harness) -> None: + harness.store.unknown_households.add(HOUSEHOLD_ID) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "The household, policy or catalog version does not exist for this country." + ) + + +class UnreachableWorkers: + """Household workers whose manifest cannot be read.""" + + def resolve(self, country: str, policyengine_version: str): + """Fail as an unreachable manifest store does.""" + raise HouseholdWorkersUnavailableError(country, policyengine_version) + + +def test_an_unreadable_manifest_asks_the_caller_to_retry( + household_client, harness +) -> None: + harness.service.workers = UnreachableWorkers() + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert response.headers["retry-after"] + + +def test_without_household_calculations_the_routes_are_unavailable() -> None: + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + ) + with TestClient(app) as test_client: + submitted = test_client.post(SUBMIT_PATH, json=submission_body()) + polled = test_client.get(f"{SUBMIT_PATH}/{uuid4()}") + + assert submitted.status_code == 503 + assert polled.status_code == 503 + + +def test_the_v2_modal_environment_is_the_call_report_environment() -> None: + settings = make_settings( + call_report_modal_workspace_id="ac-test", call_report_modal_environment="main" + ) + + assert settings.v2_modal_environment == "main" + assert make_settings().v2_modal_environment == "" + + +def test_the_simulation_database_builds_the_household_service() -> None: + database = SimulationDatabase.from_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + try: + service = database.household_calculations("staging") + finally: + database.dispose() + + assert isinstance(service, HouseholdCalculationService) + assert isinstance(service.workers, ManifestHouseholdWorkers) diff --git a/projects/policyengine-simulation-entry/tests/test_household_workers.py b/projects/policyengine-simulation-entry/tests/test_household_workers.py new file mode 100644 index 000000000..e88e5b5c6 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_workers.py @@ -0,0 +1,83 @@ +"""Finding a country's household function in the v2 version manifest.""" + +from __future__ import annotations + +import pytest +from household_fakes import POLICYENGINE_VERSION, manifest_workers +from policyengine_simulation_contract.stage12_manifest import ( + publish_v2_manifest, + v2_application_name, +) +from policyengine_simulation_entry.household_workers import ( + HouseholdFunctionUnavailableError, + HouseholdWorkersUnavailableError, + ManifestHouseholdWorkers, +) +from policyengine_simulation_entry.modal_calls import ModalTarget +from stage12_fixtures import worker + + +def test_a_countrys_household_function_and_bundle_come_from_the_manifest() -> None: + household_worker = manifest_workers().resolve("uk", POLICYENGINE_VERSION) + + assert household_worker.target == ModalTarget( + application=v2_application_name(POLICYENGINE_VERSION), + function="calculate_household_uk", + ) + assert household_worker.bundle.policyengine_version == POLICYENGINE_VERSION + assert household_worker.bundle.country_package_name == "policyengine-uk" + assert household_worker.bundle.country_package_version == "2.90.2" + assert household_worker.bundle.core_package_version == "3.30.1" + assert household_worker.bundle.bundle_manifest_sha256 == "b" * 64 + + +def test_a_version_the_manifest_lacks_has_no_household_function() -> None: + with pytest.raises(HouseholdFunctionUnavailableError, match=r"6\.0\.0"): + manifest_workers().resolve("us", "6.0.0") + + +def test_a_country_without_a_household_function_has_none() -> None: + validated = worker() + economy_only = validated.model_copy( + update={ + "countries": tuple( + country.model_copy(update={"household_callable": None}) + if country.country == "us" + else country + for country in validated.countries + ), + "validation": validated.validation.model_copy( + update={"household_validation_invocation_ids": {"uk": "household-uk"}} + ), + } + ) + store: dict[str, object] = {} + publish_v2_manifest(store=store, worker=economy_only) + + with pytest.raises(HouseholdFunctionUnavailableError, match="us"): + ManifestHouseholdWorkers.from_store(store).resolve("us", POLICYENGINE_VERSION) + + +def test_an_empty_manifest_store_means_the_workers_are_unavailable() -> None: + with pytest.raises(HouseholdWorkersUnavailableError): + ManifestHouseholdWorkers.from_store({}).resolve("us", POLICYENGINE_VERSION) + + +class UnreachableStore: + """A manifest store whose every read fails, as an unreachable Modal Dict does.""" + + def get(self, key: str, default: object = None) -> object: + """Fail the read.""" + msg = "Modal is unreachable" + raise ConnectionError(msg) + + def __setitem__(self, key: str, value: object) -> None: + """Fail the write.""" + raise ConnectionError(key) + + +def test_an_unreachable_manifest_store_means_the_workers_are_unavailable() -> None: + with pytest.raises(HouseholdWorkersUnavailableError): + ManifestHouseholdWorkers.from_store(UnreachableStore()).resolve( + "us", POLICYENGINE_VERSION + ) diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 65e79cf45..b59511e9d 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -27,6 +27,28 @@ class OpenAPIDocument(TypedDict): components: OpenAPIComponents +# The v2 household calculation routes, which only the entrypoint serves. +HOUSEHOLD_ROUTES = { + ("POST", "/v2/household-calculations"): "submit_household_calculation", + ("GET", "/v2/household-calculations/{calculation_id}"): ( + "get_household_calculation" + ), +} +# The schemas only the household routes use. +HOUSEHOLD_SCHEMAS = { + "HouseholdAxis", + "HouseholdCalculationFailure", + "HouseholdCalculationOptions", + "HouseholdCalculationResponse", + "HouseholdCalculationResult", + "HouseholdCalculationSubmission", + "HouseholdDocument", + "HouseholdGroup", + "HouseholdPolicyInput", + "HouseholdRouteError", + "RunStatus", + "RunTrigger", +} EXPECTED_ROUTES = { ("GET", "/budget-window-jobs/{batch_job_id}"), ("GET", "/health"), @@ -37,6 +59,7 @@ class OpenAPIDocument(TypedDict): ("POST", "/ping"), ("POST", "/simulate/economy/budget-window"), ("POST", "/simulate/economy/comparison"), + *HOUSEHOLD_ROUTES, } @@ -62,6 +85,8 @@ def normalized_compatibility_paths(spec: OpenAPIDocument) -> OpenAPIPathMap: """Remove intentional Cloud Run and additive SPM error documentation.""" paths = deepcopy(spec["paths"]) paths.pop("/ready", None) + for _method, household_path in HOUSEHOLD_ROUTES: + paths.pop(household_path, None) for operations in paths.values(): for method, operation in operations.items(): if method.lower() in {"get", "post", "put", "patch", "delete"}: @@ -181,9 +206,24 @@ def test_normalized_contract_matches_old_gateway(): cloud_run_schemas.pop("SimulationEntrypointReadinessResponse") cloud_run_schemas.pop("SimulationDatabaseReadiness") cloud_run_schemas.pop("SimulationErrorResponse") + for schema_name in HOUSEHOLD_SCHEMAS: + cloud_run_schemas.pop(schema_name) assert cloud_run_schemas == gateway_spec["components"]["schemas"] gateway_operation_ids = operation_ids(gateway_spec) cloud_run_operation_ids = operation_ids(cloud_run_spec) assert { key: cloud_run_operation_ids[key] for key in gateway_operation_ids } == gateway_operation_ids + + +def test_household_routes_are_public_with_stable_operation_ids() -> None: + """The generated simulation client names its methods after these IDs.""" + spec = cast("OpenAPIDocument", create_app().openapi()) + + household_operations = { + route: operation_id + for route, operation_id in operation_ids(spec).items() + if route in HOUSEHOLD_ROUTES + } + + assert household_operations == HOUSEHOLD_ROUTES From bdda635802f25e336dde47acca1f52c37f8e1882 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:51:05 +0400 Subject: [PATCH 101/124] Report a household call's end to sim as the household functions' last step calculate_household_us and calculate_household_uk take the simulation runs sim started the call for as an optional second argument. Given them, the function's last step, once the calculation and its telemetry have finished, starts report_call_ended with the call ID and those runs, whether the calculation returned or raised; a pre-empted call reports nothing, since Modal runs it again under the same ID. Release validation and the image check pass no runs, and nothing is reported. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 8 +- .../src/modal/v2_app.py | 56 ++++++++- .../tests/test_household_call_reports.py | 118 ++++++++++++++++++ 3 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 projects/policyengine-simulation-executor/tests/test_household_call_reports.py diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index db3be799a..afc257973 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -415,7 +415,13 @@ its reports may be given to the report coordinator) and which function calculates its households. The Simulation Entrypoint's comparison adapter skips any country the manifest does not mark as economy-capable, with the reason `unsupported_country`, so the coordinator receives only those -countries' reports. +countries' reports. Its household calculation routes look a calculation's +household function up by its PolicyEngine.py version and country, and pass +the simulation runs the call computes as the function's second argument; the +function's last step then reports the call's end to the entrypoint +(`src/modal/call_reports.py`), which records its outcome. Release validation +and the image check call the household functions without runs, and nothing is +reported for them. Readers released before a schema change reject fields they do not know, and the manifest is published before the Cloud Run revision that reads it is diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index c5625b198..ebe10cc05 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -13,9 +13,12 @@ from __future__ import annotations import json +import logging import os +from contextlib import nullcontext from pathlib import Path from typing import TYPE_CHECKING +from uuid import UUID from policyengine_simulation_contract.household_execution import ( household_callable_name, @@ -62,6 +65,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from contextlib import AbstractContextManager from policyengine_simulation_contract.stage12_bundle import ( CountryId, @@ -543,6 +547,38 @@ def coordinate_report( HOUSEHOLD_FUNCTION_MAX_CONTAINERS = 20 +def _reporting_household_call( + simulation_run_ids: list[str] | None, +) -> AbstractContextManager[None]: + """Report the call's end to sim when it computes runs sim started. + + sim passes the runs a call computes; release validation and the image + check pass none, and nothing is reported. The report is the last step: + it starts ``report_call_ended`` once the calculation and its telemetry + have finished, whether the calculation returned or raised, but not when + Modal pre-empts the call, which Modal runs again under the same ID. + """ + if simulation_run_ids is None: + return nullcontext() + try: + run_ids = [UUID(run_id) for run_id in simulation_run_ids] + except (TypeError, ValueError): + logging.getLogger(__name__).warning( + "call_report_malformed", + extra={"modal_call_id": modal.current_function_call_id()}, + ) + return nullcontext() + from src.modal.call_reports import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + reporting_call_end, + ) + + return reporting_call_end( + modal_call_id=modal.current_function_call_id(), + simulation_run_ids=run_ids, + start_report=report_call_ended.spawn, + ) + + def _run_household_calculation( country: CountryId, payload: dict, @@ -606,11 +642,17 @@ def _run_household_calculation( ) def calculate_household_us( payload: dict, + simulation_run_ids: list[str] | None = None, *, observability_context: dict | None = None, ) -> dict: - """Calculate one US household under each requested policy.""" - return _run_household_calculation("us", payload, observability_context) + """Calculate one US household under each requested policy. + + ``simulation_run_ids`` names the runs sim started this call for; the + call's last step reports their end to sim. + """ + with _reporting_household_call(simulation_run_ids): + return _run_household_calculation("us", payload, observability_context) @app.function( @@ -624,11 +666,17 @@ def calculate_household_us( ) def calculate_household_uk( payload: dict, + simulation_run_ids: list[str] | None = None, *, observability_context: dict | None = None, ) -> dict: - """Calculate one UK household under each requested policy.""" - return _run_household_calculation("uk", payload, observability_context) + """Calculate one UK household under each requested policy. + + ``simulation_run_ids`` names the runs sim started this call for; the + call's last step reports their end to sim. + """ + with _reporting_household_call(simulation_run_ids): + return _run_household_calculation("uk", payload, observability_context) @app.function( diff --git a/projects/policyengine-simulation-executor/tests/test_household_call_reports.py b/projects/policyengine-simulation-executor/tests/test_household_call_reports.py new file mode 100644 index 000000000..9eb484a27 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_call_reports.py @@ -0,0 +1,118 @@ +"""A household function's last step reports that its call ended to sim.""" + +from __future__ import annotations + +import sys +from uuid import uuid4 + +import pytest +from test_stage12_modal_app import ( + RecordingRuntime, + _household_payload, + _load, +) + +HOUSEHOLD_CALL_ID = "fc-household" + + +class Outcome: + """A household calculation outcome, as the calculation returns it.""" + + def model_dump(self, *, mode): + """Return the outcome as JSON.""" + assert mode == "json" + return {"status": "succeeded"} + + +def _module_reporting_to(monkeypatch, reports: list, calculate): + """Load the v2 app with a call ID, a report recorder and ``calculate``.""" + from policyengine_simulation_executor import household_calculation + + module = _load(monkeypatch) + monkeypatch.setattr( + module, "init_process_observability", lambda **_: RecordingRuntime() + ) + monkeypatch.setattr( + sys.modules["modal"], + "current_function_call_id", + lambda: HOUSEHOLD_CALL_ID, + raising=False, + ) + monkeypatch.setattr( + module.report_call_ended, "spawn", reports.append, raising=False + ) + monkeypatch.setattr(household_calculation, "calculate_household", calculate) + return module + + +def test_a_household_call_reports_its_runs_when_it_ends(monkeypatch) -> None: + reports: list = [] + module = _module_reporting_to(monkeypatch, reports, lambda *_, **__: Outcome()) + baseline_run, reform_run = str(uuid4()), str(uuid4()) + + result = module.calculate_household_us( + _household_payload("us"), [baseline_run, reform_run] + ) + + assert result == {"status": "succeeded"} + assert reports == [ + { + "modal_call_id": HOUSEHOLD_CALL_ID, + "simulation_run_ids": [baseline_run, reform_run], + "report_run_id": None, + } + ] + + +def test_a_household_call_that_raises_still_reports(monkeypatch) -> None: + reports: list = [] + + def fail(*_, **__): + msg = "the country model could not load" + raise RuntimeError(msg) + + module = _module_reporting_to(monkeypatch, reports, fail) + run_id = str(uuid4()) + + with pytest.raises(RuntimeError, match="could not load"): + module.calculate_household_uk(_household_payload("uk"), [run_id]) + + assert [report["simulation_run_ids"] for report in reports] == [[run_id]] + + +def test_a_pre_empted_household_call_reports_nothing(monkeypatch) -> None: + """Modal re-runs a pre-empted call under the same ID; the re-run reports.""" + reports: list = [] + + def pre_empt(*_, **__): + raise KeyboardInterrupt + + module = _module_reporting_to(monkeypatch, reports, pre_empt) + + with pytest.raises(KeyboardInterrupt): + module.calculate_household_us(_household_payload("us"), [str(uuid4())]) + + assert reports == [] + + +def test_a_household_call_without_runs_reports_nothing(monkeypatch) -> None: + """Release validation and the image check call the functions without runs.""" + reports: list = [] + module = _module_reporting_to(monkeypatch, reports, lambda *_, **__: Outcome()) + + assert module.calculate_household_us(_household_payload("us")) == { + "status": "succeeded" + } + assert reports == [] + + +def test_run_ids_that_are_not_uuids_report_nothing_but_still_calculate( + monkeypatch, +) -> None: + reports: list = [] + module = _module_reporting_to(monkeypatch, reports, lambda *_, **__: Outcome()) + + result = module.calculate_household_us(_household_payload("us"), ["not-a-run"]) + + assert result == {"status": "succeeded"} + assert reports == [] From 39c59e9e88f39c7f5dd630b450eb002886bf8b25 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 19:54:42 +0400 Subject: [PATCH 102/124] Store household reports and read them with both calculations HouseholdCalculationStore creates household reports and their runs, and read_report returns a household report with one of its report runs (the newest, unless one is named) and that run's baseline and reform runs as household calculations, each with its household once it succeeded. A report with only a baseline has no reform, a report before its first run has neither, and an economy report, or another report's run, reads as none. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 6 + .../__init__.py | 2 + .../household_calculations.py | 150 ++++++++++++++--- .../tests/test_household_reports.py | 153 ++++++++++++++++++ 4 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/tests/test_household_reports.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 591337576..a479253b6 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -44,6 +44,12 @@ older row. `start_run` starts or reuses its run, as `RunStore` does, and detail), its simulation and, once it succeeded, the computed household; an economy run is not a household calculation and reads as none. +A household report compares a baseline household simulation with an +optional reform. Each report run joins one run of each, and one Modal call +computes both households. `create_report` and `create_report_run` add them, +and `read_report` returns the report with a report run (its newest, unless +one is named) and both runs as calculations; an economy report reads as none. + ### Runs A simulation run's setup is its catalog version (one policyengine.py bundle diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 574f6ba08..5b42c5d31 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -22,6 +22,7 @@ from policyengine_simulation_persistence.household_calculations import ( HouseholdCalculation, HouseholdCalculationStore, + HouseholdReport, ) from policyengine_simulation_persistence.household_results import ( HouseholdOutput, @@ -92,6 +93,7 @@ "HouseholdCalculation", "HouseholdCalculationStore", "HouseholdOutput", + "HouseholdReport", "NewReport", "NewReportRun", "NewSimulation", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py index 51cc31968..fec6a388c 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py @@ -14,6 +14,11 @@ ``read_calculation`` returns a calculation as its reader sees it: the run's status, timings and error (with its detail), its simulation, and, once it succeeded, the computed household. + +A household report compares a baseline household simulation with an +optional reform. Each report run joins one run of each, and one Modal call +computes both households; ``read_report`` returns the report with a report +run (its newest, unless another is named) and both runs as calculations. """ from __future__ import annotations @@ -28,10 +33,18 @@ HouseholdOutput, read_household_output, ) +from policyengine_simulation_persistence.reports import ( + ReportKind, + ReportRecord, + ReportStore, + reports, +) from policyengine_simulation_persistence.run_store import RunStore, utc_now from policyengine_simulation_persistence.runs import ( + ReportRunRecord, RunStatus, SimulationRunRecord, + report_runs, simulation_runs, ) from policyengine_simulation_persistence.simulations import ( @@ -46,9 +59,11 @@ from datetime import datetime from uuid import UUID - from sqlalchemy import Engine + from sqlalchemy import Connection, Engine + from policyengine_simulation_persistence.reports import NewReport from policyengine_simulation_persistence.runs import ( + NewReportRun, NewSimulationRun, StartedSimulationRun, ) @@ -67,6 +82,20 @@ class HouseholdCalculation: output: HouseholdOutput | None +@dataclass(frozen=True) +class HouseholdReport: + """A household report, one of its runs, and that run's two calculations. + + ``report_run``, ``baseline`` and ``reform`` are None before the report's + first run; ``reform`` is None for a report with only a baseline. + """ + + report: ReportRecord + report_run: ReportRunRecord | None + baseline: HouseholdCalculation | None + reform: HouseholdCalculation | None + + class HouseholdCalculationStore: """Typed SQLAlchemy Core reads and inserts for household calculations.""" @@ -79,6 +108,7 @@ def __init__( """Use ``engine`` (the runtime role) and ``clock`` for run timestamps.""" self.engine = engine self._simulations = SimulationStore(engine) + self._reports = ReportStore(engine) self._runs = RunStore(engine, clock=clock) def find_or_create_simulation( @@ -129,40 +159,122 @@ def start_run( """ return self._runs.start_simulation_run(new_run, force=force) + def get_simulation(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + return self._simulations.get(simulation_id) + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: """Return the household calculation run ``run_id``, or None. An economy run is not a household calculation, so it reads as None. """ with self.engine.connect() as connection: - row = ( + return _read_calculation(connection, run_id) + + # --- Household reports -------------------------------------------------- + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Insert one report and return it.""" + return self._reports.create(new_report) + + def get_report(self, report_id: UUID) -> ReportRecord | None: + """Return the household report with this ID, or None.""" + report = self._reports.get(report_id) + if report is None or report.kind is not ReportKind.HOUSEHOLD: + return None + return report + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Add a report run joining runs of the report's simulations.""" + return self._runs.create_report_run(new_report_run) + + def read_report( + self, report_id: UUID, *, report_run_id: UUID | None = None + ) -> HouseholdReport | None: + """Return a household report with a run and its two calculations. + + The run is ``report_run_id`` when given, and the report's newest run + otherwise. Return None for an economy report, an unknown report, or a + run of another report. + """ + with self.engine.connect() as connection: + report_row = ( connection.execute( - select(simulation_runs).where( - simulation_runs.c.id == run_id, - simulation_runs.c.simulation_kind - == SimulationKind.HOUSEHOLD.value, + select(reports).where( + reports.c.id == report_id, + reports.c.kind == ReportKind.HOUSEHOLD.value, ) ) .mappings() .first() ) - if row is None: + if report_row is None: return None - run = SimulationRunRecord.from_row(row) - simulation_row = ( + run_query = select(report_runs).where(report_runs.c.report_id == report_id) + if report_run_id is not None: + run_query = run_query.where(report_runs.c.id == report_run_id) + run_row = ( connection.execute( - select(simulations).where(simulations.c.id == run.simulation_id) + run_query.order_by( + report_runs.c.created_at.desc(), report_runs.c.id.desc() + ).limit(1) ) .mappings() - .one() + .first() + ) + if run_row is None and report_run_id is not None: + return None + report_run = None if run_row is None else ReportRunRecord.from_row(run_row) + baseline = ( + None + if report_run is None + else _read_calculation( + connection, report_run.baseline_simulation_run_id + ) ) - output = ( - read_household_output(connection, run.id) - if run.status is RunStatus.SUCCEEDED - else None + reform = ( + None + if report_run is None or report_run.reform_simulation_run_id is None + else _read_calculation(connection, report_run.reform_simulation_run_id) ) - return HouseholdCalculation( - run=run, - simulation=SimulationRecord.from_row(simulation_row), - output=output, + return HouseholdReport( + report=ReportRecord.from_row(report_row), + report_run=report_run, + baseline=baseline, + reform=reform, ) + + +def _read_calculation( + connection: Connection, run_id: UUID +) -> HouseholdCalculation | None: + row = ( + connection.execute( + select(simulation_runs).where( + simulation_runs.c.id == run_id, + simulation_runs.c.simulation_kind == SimulationKind.HOUSEHOLD.value, + ) + ) + .mappings() + .first() + ) + if row is None: + return None + run = SimulationRunRecord.from_row(row) + simulation_row = ( + connection.execute( + select(simulations).where(simulations.c.id == run.simulation_id) + ) + .mappings() + .one() + ) + output = ( + read_household_output(connection, run.id) + if run.status is RunStatus.SUCCEEDED + else None + ) + return HouseholdCalculation( + run=run, + simulation=SimulationRecord.from_row(simulation_row), + output=output, + ) diff --git a/libs/policyengine-simulation-persistence/tests/test_household_reports.py b/libs/policyengine-simulation-persistence/tests/test_household_reports.py new file mode 100644 index 000000000..be002f7a7 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_reports.py @@ -0,0 +1,153 @@ +"""Household reports: two household simulations' runs, sharing one Modal call.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + HouseholdCalculationStore, + NewReport, + NewReportRun, + ReportKind, + RunStatus, + RunTrigger, + create_simulation_engine, +) +from report_support import economy_pair, economy_report +from run_support import household_run +from simulation_support import household_simulation + +pytestmark = pytest.mark.postgres + + +def household_store(database) -> HouseholdCalculationStore: + """Return a household calculation store that connects as the runtime role.""" + return HouseholdCalculationStore(create_simulation_engine(database.runtime_url)) + + +def _report_with_run(store, rows, *, reform_policy_id=None, trigger=None): + """Create a 2026 household report and one run of it; return both.""" + baseline = store.find_or_create_simulation(household_simulation(rows)) + reform = ( + None + if reform_policy_id is None + else store.find_or_create_simulation( + household_simulation(rows, policy_id=reform_policy_id) + ) + ) + report = store.create_report( + NewReport( + kind=ReportKind.HOUSEHOLD, + country="us", + baseline_simulation_id=baseline.id, + reform_simulation_id=None if reform is None else reform.id, + year=2026, + ) + ) + baseline_run = store.start_run(household_run(baseline, rows)).run + reform_run = ( + None if reform is None else store.start_run(household_run(reform, rows)).run + ) + report_run = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=None if reform_run is None else reform_run.id, + trigger=trigger or RunTrigger.INITIAL, + ) + ) + return report, report_run + + +def test_a_household_report_reads_back_with_both_calculations( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, report_run = _report_with_run( + store, public_api_rows, reform_policy_id=public_api_rows.us_policy_id + ) + + read = store.read_report(report.id) + + assert read.report.id == report.id + assert read.report_run.id == report_run.id + assert read.baseline.run.id == report_run.baseline_simulation_run_id + assert read.baseline.simulation.policy_id is None + assert read.reform.run.id == report_run.reform_simulation_run_id + assert read.reform.simulation.policy_id == public_api_rows.us_policy_id + assert read.baseline.run.status is RunStatus.PENDING + assert read.baseline.output is None + + +def test_a_baseline_only_household_report_has_no_reform( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, _ = _report_with_run(store, public_api_rows) + + read = store.read_report(report.id) + + assert read.report.reform_simulation_id is None + assert read.reform is None + + +def test_a_report_reads_its_newest_run_unless_another_is_named( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, first_run = _report_with_run(store, public_api_rows) + second_run = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=first_run.baseline_simulation_run_id, + trigger=RunTrigger.MANUAL, + ) + ) + + assert store.read_report(report.id).report_run.id == second_run.id + assert ( + store.read_report(report.id, report_run_id=first_run.id).report_run.id + == first_run.id + ) + + +def test_a_report_without_runs_reads_back_without_calculations( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + baseline = store.find_or_create_simulation(household_simulation(public_api_rows)) + report = store.create_report( + NewReport( + kind=ReportKind.HOUSEHOLD, + country="us", + baseline_simulation_id=baseline.id, + year=2026, + ) + ) + + read = store.read_report(report.id) + + assert read.report_run is None + assert read.baseline is None + assert read.reform is None + assert store.get_report(report.id) == report + assert store.get_simulation(baseline.id) == baseline + + +def test_only_household_reports_and_their_own_runs_are_read( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, _ = _report_with_run(store, public_api_rows) + other_report, other_run = _report_with_run( + store, public_api_rows, reform_policy_id=public_api_rows.us_policy_id + ) + baseline, reform = economy_pair(migrated_database, public_api_rows) + economy = store.create_report(economy_report(baseline, reform)) + + assert store.read_report(economy.id) is None + assert store.get_report(economy.id) is None + assert store.read_report(uuid4()) is None + assert store.read_report(report.id, report_run_id=other_run.id) is None + assert store.read_report(other_report.id, report_run_id=other_run.id) is not None From 81f6b04b1fdf80cd7dbb703645e76a3bb73a2338 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:01:52 +0400 Subject: [PATCH 103/124] Create, run again and read household reports on the Simulation Entrypoint POST /v2/household-reports creates a household report (the household under a baseline and an optional reform, for a year) and starts its first run; POST /v2/household-reports/{id}/runs starts another run of it, with the report's own household and policies (400 otherwise); GET /v2/household-reports/{id} returns it with its newest run and that run's two calculations. A report run has failed when either calculation failed and succeeded when both did. Each report run joins a run of each simulation, reused as any calculation's run is, and one Modal call computes the runs still pending, so a new report's two households are calculated together. When another submitter claimed one of them first, that submitter's call computes it, and this one starts a call for the rest. Co-Authored-By: Claude Opus 5.5 --- .../policyengine-simulation-entry/README.md | 13 + .../household_api_models.py | 180 ++++++++-- .../household_calculations.py | 339 ++++++++++++++++-- .../household_routes.py | 240 ++++++++++--- .../tests/household_fakes.py | 90 +++++ .../tests/household_harness.py | 6 +- .../tests/test_household_report_routes.py | 161 +++++++++ .../tests/test_household_reports.py | 231 ++++++++++++ .../tests/test_openapi.py | 8 +- 9 files changed, 1174 insertions(+), 94 deletions(-) create mode 100644 projects/policyengine-simulation-entry/tests/test_household_report_routes.py create mode 100644 projects/policyengine-simulation-entry/tests/test_household_reports.py diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index f7c0cabcb..3d7079409 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -48,6 +48,16 @@ reports when its call ends, and the outcome is recorded then (`DEPLOYMENT.md`, "Recording run outcomes"). Without the simulation database or that environment, the routes answer 503. +A household report compares the household under a baseline and an optional +reform. `POST /v2/household-reports` creates it and starts its first run; +`POST /v2/household-reports/{report_id}/runs` starts another run of it, with +the report's own household and policies; `GET /v2/household-reports/{report_id}` +returns it with its newest run and that run's two calculations. Each report +run joins a run of each simulation, reused like any calculation's, and one +Modal call computes the runs that still need computing, so a new report's +two households are calculated together. A report run has failed when either +calculation failed and succeeded when both did. + ## Caller authentication and permissions Protected routes accept Auth0 RS256 access tokens issued by @@ -63,6 +73,9 @@ Auth0 permission: | `GET /budget-window-jobs/{batch_job_id}` | `simulation:read` | | `POST /v2/household-calculations` | `simulation:submit` | | `GET /v2/household-calculations/{calculation_id}` | `simulation:read` | +| `POST /v2/household-reports` | `simulation:submit` | +| `POST /v2/household-reports/{report_id}/runs` | `simulation:submit` | +| `GET /v2/household-reports/{report_id}` | `simulation:read` | | `POST /internal/stage12/reports` | `stage12:operate` | | `GET /internal/stage12/reports/{evaluation_id}` | `stage12:operate` | diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py index 0119766f3..97e59f4b5 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -41,7 +41,10 @@ ) if TYPE_CHECKING: - from policyengine_simulation_persistence import HouseholdCalculation + from policyengine_simulation_persistence import ( + HouseholdCalculation, + HouseholdReport, + ) PolicyParameters = Annotated[ dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], @@ -99,31 +102,26 @@ def stored(self) -> dict[str, Any]: return stored -class HouseholdCalculationSubmission(StrictRouteModel): - """One household under one policy, to calculate for a catalog version and year. +PolicyEngineVersion = Annotated[str, Field(min_length=1, max_length=255)] +Year = Annotated[int, Field(ge=FIRST_YEAR, le=LAST_YEAR)] + + +class StoredHouseholdInputs(StrictRouteModel): + """A stored household and the catalog version to calculate it on. ``catalog_version_id`` is the Public API's catalog version and ``policyengine_version`` the PolicyEngine.py version it was published from, which picks the household function. ``household`` is the stored household in the situation shape, without axes (they are an option); ``spm`` is its saved SPM choice, omitted when it has none, so the - bundle's certified defaults apply. A calculation submitted again reuses - its run while it is unfinished or once it succeeded, unless ``force``. + bundle's certified defaults apply. """ - country: CountryId catalog_version_id: UUID - policyengine_version: Annotated[str, Field(min_length=1, max_length=255)] - year: Annotated[int, Field(ge=FIRST_YEAR, le=LAST_YEAR)] + policyengine_version: PolicyEngineVersion household_id: UUID household: HouseholdDocument spm: JsonValue = None - policy: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) - options: HouseholdCalculationOptions = Field( - default_factory=HouseholdCalculationOptions - ) - force: bool = False - trigger: RunTrigger = RunTrigger.INITIAL @field_validator("household") @classmethod @@ -141,17 +139,84 @@ def spm_requested(self) -> bool: """Whether the household has a saved SPM choice to calculate with.""" return "spm" in self.model_fields_set + +class HouseholdCalculationSubmission(StoredHouseholdInputs): + """One household under one policy, to calculate for a catalog version and year. + + A calculation submitted again reuses its run while it is unfinished or + once it succeeded, unless ``force``. + """ + + country: CountryId + year: Year + policy: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) + options: HouseholdCalculationOptions = Field( + default_factory=HouseholdCalculationOptions + ) + force: bool = False + trigger: RunTrigger = RunTrigger.INITIAL + def simulation_definition(self) -> NewSimulation: """Return the household simulation this calculation runs.""" - return NewSimulation( - kind=SimulationKind.HOUSEHOLD, - country=self.country, - household_id=self.household_id, - policy_id=self.policy.policy_id, - options=self.options.stored(), + return household_simulation_definition( + self, self.country, self.policy, self.options ) +class HouseholdReportSubmission(StoredHouseholdInputs): + """A household report to create: the household under a baseline and a reform. + + ``reform`` is omitted for a report of the baseline alone. Both policies + are calculated with the same ``options``, in one Modal call when neither + has a run to reuse. The report's first run starts at once. + """ + + country: CountryId + year: Year + baseline: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) + reform: HouseholdPolicyInput | None = None + options: HouseholdCalculationOptions = Field( + default_factory=HouseholdCalculationOptions + ) + force: bool = False + trigger: RunTrigger = RunTrigger.INITIAL + + def simulation_definition(self, policy: HouseholdPolicyInput) -> NewSimulation: + """Return the household simulation of one of the report's policies.""" + return household_simulation_definition(self, self.country, policy, self.options) + + +class HouseholdReportRunSubmission(StoredHouseholdInputs): + """Another run of a stored household report, on a catalog version. + + The household and both policies must be the report's own: the Public API + sends their contents again, because the Simulation Entrypoint cannot + read them. The runs are reused while unfinished or once they succeeded, + unless ``force``. + """ + + baseline: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) + reform: HouseholdPolicyInput | None = None + force: bool = False + trigger: RunTrigger = RunTrigger.MANUAL + + +def household_simulation_definition( + inputs: StoredHouseholdInputs, + country: str, + policy: HouseholdPolicyInput, + options: HouseholdCalculationOptions, +) -> NewSimulation: + """Return the household simulation of ``inputs`` under ``policy``.""" + return NewSimulation( + kind=SimulationKind.HOUSEHOLD, + country=country, + household_id=inputs.household_id, + policy_id=policy.policy_id, + options=options.stored(), + ) + + class HouseholdCalculationResult(StrictRouteModel): """A succeeded calculation's household, warnings, SPM choice and bundle. @@ -257,6 +322,81 @@ def from_calculation(cls, calculation: HouseholdCalculation) -> Self: ) +def household_report_status(report: HouseholdReport) -> RunStatus | None: + """Return a household report run's status, from its simulation runs. + + It has failed when either run failed and succeeded when both did; + otherwise it is the less advanced of the two. None before the first run. + """ + statuses = { + calculation.run.status + for calculation in (report.baseline, report.reform) + if calculation is not None + } + if not statuses: + return None + if RunStatus.FAILED in statuses: + return RunStatus.FAILED + for status in (RunStatus.PENDING, RunStatus.DISPATCHING, RunStatus.RUNNING): + if status in statuses: + return status + return RunStatus.SUCCEEDED + + +class HouseholdReportResponse(StrictRouteModel): + """A household report with one of its runs, and that run's two calculations. + + The run is the report's newest, unless the response answers the request + that started another. ``status`` is the run's status: failed when either + calculation failed, succeeded when both did, and otherwise the less + advanced of the two. The run fields are null before the first run; + ``reform`` is null for a report of the baseline alone. + """ + + report_id: UUID + country: str + year: int + household_id: UUID | None + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + report_run_id: UUID | None + catalog_version_id: UUID | None + trigger: RunTrigger | None + status: RunStatus | None + baseline: HouseholdCalculationResponse | None + reform: HouseholdCalculationResponse | None + + @classmethod + def from_report(cls, report: HouseholdReport) -> Self: + """Describe a stored household report and one of its runs.""" + report_run = report.report_run + baseline = ( + None + if report.baseline is None + else HouseholdCalculationResponse.from_calculation(report.baseline) + ) + return cls( + report_id=report.report.id, + country=report.report.country, + year=report.report.year, + household_id=None if baseline is None else baseline.household_id, + baseline_simulation_id=report.report.baseline_simulation_id, + reform_simulation_id=report.report.reform_simulation_id, + report_run_id=None if report_run is None else report_run.id, + catalog_version_id=( + None if report_run is None else report_run.tax_benefit_model_version_id + ), + trigger=None if report_run is None else report_run.trigger, + status=household_report_status(report), + baseline=baseline, + reform=( + None + if report.reform is None + else HouseholdCalculationResponse.from_calculation(report.reform) + ), + ) + + class HouseholdRouteError(BaseModel): """Why a household route refused or could not serve a request.""" diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index a1ba19f8f..040ca8db7 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -20,6 +20,13 @@ Reading returns the run as it stands: status, error and, once it succeeded, the household. Nothing waits for Modal. +A household report compares the household under a baseline and a reform. +Creating one, or running a stored one again, starts or reuses a run of each +of its simulations, joins them in a report run, and starts one Modal call +for those still pending, so a new report's two households are calculated +together. When another submitter claimed one of them first, it starts that +one's call, and this submitter starts a call for the rest. + This is v2 code: the main path's ``run_simulation`` does not use it. """ @@ -27,19 +34,29 @@ import asyncio from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, Protocol +from typing import TYPE_CHECKING, Protocol from policyengine_simulation_contract.household_execution import ( HouseholdExecutionRequest, ) -from policyengine_simulation_persistence import NewSimulationRun, RunStatus +from policyengine_simulation_persistence import ( + NewReport, + NewReportRun, + NewSimulationRun, + ReportKind, + RunStatus, +) from sqlalchemy.exc import IntegrityError +from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationOptions, +) from policyengine_simulation_entry.household_recording import policy_key_for_run from policyengine_simulation_entry.modal_calls import ModalRequest +from policyengine_simulation_entry.run_dispatch import DispatchOutcome if TYPE_CHECKING: - from collections.abc import Collection, Mapping, Sequence + from collections.abc import Callable, Collection, Mapping, Sequence from uuid import UUID from policyengine_simulation_contract.household_execution import ( @@ -47,28 +64,46 @@ ) from policyengine_simulation_persistence import ( HouseholdCalculation, + HouseholdReport, NewSimulation, + ReportRecord, + ReportRunRecord, + RunTrigger, SimulationRecord, + SimulationRunRecord, StartedSimulationRun, ) from pydantic import JsonValue from policyengine_simulation_entry.household_api_models import ( - HouseholdCalculationOptions, HouseholdCalculationSubmission, + HouseholdPolicyInput, + HouseholdReportRunSubmission, + HouseholdReportSubmission, ) from policyengine_simulation_entry.household_workers import HouseholdWorker - from policyengine_simulation_entry.run_dispatch import DispatchOutcome + + # Each of a report's simulations with the policy it calculates. + type ReportSides = Sequence[tuple[SimulationRecord, HouseholdPolicyInput]] UNKNOWN_REFERENCE_MESSAGE = ( "The household, policy or catalog version does not exist for this country." ) +MISMATCHED_REPORT_MESSAGE = ( + "A report's run calculates the report's own household and policies." +) + + class UnknownHouseholdReferenceError(LookupError): """The household, policy or catalog version does not exist for the country.""" +class ReportPoliciesMismatchError(ValueError): + """A report's run names another household or other policies than the report.""" + + class HouseholdCalculationRecords(Protocol): """The ``HouseholdCalculationStore`` reads and writes a submission needs.""" @@ -88,6 +123,28 @@ def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: """Return the household calculation run ``run_id``, or None.""" ... + def get_simulation(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + ... + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Insert one report and return it.""" + ... + + def get_report(self, report_id: UUID) -> ReportRecord | None: + """Return the household report with this ID, or None.""" + ... + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Add a report run joining runs of the report's simulations.""" + ... + + def read_report( + self, report_id: UUID, *, report_run_id: UUID | None = None + ) -> HouseholdReport | None: + """Return a household report with a run and its two calculations.""" + ... + class SimulationRunStarter(Protocol): """Start the one Modal call that computes some runs (``RunDispatchService``).""" @@ -111,7 +168,7 @@ class HouseholdInputs(Protocol): """What a household's Modal call calculates, besides its policies.""" @property - def country(self) -> Literal["us", "uk"]: + def country(self) -> str: """The household's country.""" ... @@ -136,6 +193,38 @@ def options(self) -> HouseholdCalculationOptions: ... +@dataclass(frozen=True) +class ReportRunInputs: + """What a stored report's Modal call calculates, besides its policies.""" + + country: str + household: HouseholdDocument + spm: JsonValue + spm_requested: bool + options: HouseholdCalculationOptions + + +def _check_report_sides( + report: ReportRecord, + sides: ReportSides, + submission: HouseholdReportRunSubmission, +) -> None: + """Refuse a run that names another household or other policies.""" + submitted = [submission.baseline] + ( + [] if submission.reform is None else [submission.reform] + ) + if ( + len(submitted) != len(sides) + or (report.reform_simulation_id is None) != (submission.reform is None) + or any( + simulation.household_id != submission.household_id + or simulation.policy_id != policy.policy_id + for (simulation, _), policy in zip(sides, submitted, strict=False) + ) + ): + raise ReportPoliciesMismatchError(MISMATCHED_REPORT_MESSAGE) + + def household_modal_request( worker: HouseholdWorker, *, @@ -198,27 +287,7 @@ async def submit( worker = await asyncio.to_thread( self.workers.resolve, submission.country, submission.policyengine_version ) - started = await self._start(submission) - run = started.run - if run.status is RunStatus.PENDING: - await self.dispatch.start_simulation_runs( - [run.id], - household_modal_request( - worker, - calculation_id=run.id, - inputs=submission, - run_policies=[(run.id, submission.policy.parameters)], - ), - ) - calculation = await self.read(run.id) - if calculation is None: # pragma: no cover - runs are never deleted - msg = f"household calculation {run.id} disappeared" - raise RuntimeError(msg) - return calculation - async def _start( - self, submission: HouseholdCalculationSubmission - ) -> StartedSimulationRun: def start() -> StartedSimulationRun: simulation = self.store.find_or_create_simulation( submission.simulation_definition() @@ -233,11 +302,221 @@ def start() -> StartedSimulationRun: force=submission.force, ) - try: - return await asyncio.to_thread(start) - except IntegrityError as error: - raise UnknownHouseholdReferenceError(UNKNOWN_REFERENCE_MESSAGE) from error + run = (await self._write(start)).run + await self._start_pending_runs( + worker, + calculation_id=run.id, + inputs=submission, + runs=[(run, submission.policy.parameters)], + ) + calculation = await self.read(run.id) + if calculation is None: # pragma: no cover - runs are never deleted + msg = f"household calculation {run.id} disappeared" + raise RuntimeError(msg) + return calculation async def read(self, calculation_id: UUID) -> HouseholdCalculation | None: """Return the household calculation, or None if there is none.""" return await asyncio.to_thread(self.store.read_calculation, calculation_id) + + async def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReport: + """Create a household report and start its first run. + + Raise as ``submit`` does. + """ + worker = await asyncio.to_thread( + self.workers.resolve, submission.country, submission.policyengine_version + ) + policies = [submission.baseline] + ( + [] if submission.reform is None else [submission.reform] + ) + + def create() -> tuple[ReportRecord, list[SimulationRecord]]: + simulations = [ + self.store.find_or_create_simulation( + submission.simulation_definition(policy) + ) + for policy in policies + ] + report = self.store.create_report( + NewReport( + kind=ReportKind.HOUSEHOLD, + country=submission.country, + baseline_simulation_id=simulations[0].id, + reform_simulation_id=( + simulations[1].id if len(simulations) > 1 else None + ), + year=submission.year, + ) + ) + return report, simulations + + report, simulations = await self._write(create) + return await self._run_report( + worker, + report, + inputs=submission, + sides=list(zip(simulations, policies, strict=True)), + submission=submission, + ) + + async def rerun_report( + self, report_id: UUID, submission: HouseholdReportRunSubmission + ) -> HouseholdReport | None: + """Start another run of a stored household report; None if there is none. + + Raise ReportPoliciesMismatchError when the submission names another + household or other policies, and otherwise as ``submit`` does. + """ + + def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: + report = self.store.get_report(report_id) + if report is None: + return None + simulation_ids = [report.baseline_simulation_id] + ( + [] + if report.reform_simulation_id is None + else [report.reform_simulation_id] + ) + simulations = [ + self.store.get_simulation(simulation_id) + for simulation_id in simulation_ids + ] + return report, [ + simulation for simulation in simulations if simulation is not None + ] + + loaded = await asyncio.to_thread(load) + if loaded is None: + return None + report, simulations = loaded + policies = [submission.baseline] + ( + [] if submission.reform is None else [submission.reform] + ) + sides = list(zip(simulations, policies, strict=False)) + _check_report_sides(report, sides, submission) + worker = await asyncio.to_thread( + self.workers.resolve, report.country, submission.policyengine_version + ) + inputs = ReportRunInputs( + country=report.country, + household=submission.household, + spm=submission.spm, + spm_requested=submission.spm_requested, + options=HouseholdCalculationOptions.model_validate(simulations[0].options), + ) + return await self._run_report( + worker, report, inputs=inputs, sides=sides, submission=submission + ) + + async def read_report(self, report_id: UUID) -> HouseholdReport | None: + """Return a household report with its newest run, or None.""" + return await asyncio.to_thread(self.store.read_report, report_id) + + async def _run_report( + self, + worker: HouseholdWorker, + report: ReportRecord, + *, + inputs: HouseholdInputs, + sides: ReportSides, + submission: HouseholdReportSubmission | HouseholdReportRunSubmission, + ) -> HouseholdReport: + """Start or reuse a run of each side, join them, and start the pending.""" + trigger: RunTrigger = submission.trigger + + def start() -> tuple[list[SimulationRunRecord], ReportRunRecord]: + # A report comparing a simulation with itself runs it once. + started: dict[UUID, SimulationRunRecord] = {} + for simulation, _ in sides: + if simulation.id not in started: + started[simulation.id] = self.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=submission.catalog_version_id, + year=report.year, + trigger=trigger, + ), + force=submission.force, + ).run + runs = [started[simulation.id] for simulation, _ in sides] + report_run = self.store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=runs[0].id, + reform_simulation_run_id=runs[1].id if len(runs) > 1 else None, + trigger=trigger, + ) + ) + return runs, report_run + + runs, report_run = await self._write(start) + unique_runs = { + run.id: (run, policy.parameters) + for run, (_, policy) in zip(runs, sides, strict=True) + } + await self._start_pending_runs( + worker, + calculation_id=report_run.id, + inputs=inputs, + runs=list(unique_runs.values()), + ) + read = await asyncio.to_thread( + lambda: self.store.read_report(report.id, report_run_id=report_run.id) + ) + if read is None: # pragma: no cover - reports are never deleted + msg = f"household report {report.id} disappeared" + raise RuntimeError(msg) + return read + + async def _start_pending_runs( + self, + worker: HouseholdWorker, + *, + calculation_id: UUID, + inputs: HouseholdInputs, + runs: Sequence[tuple[SimulationRunRecord, Mapping[str, object]]], + ) -> None: + """Start one call for the runs that are pending, whoever created them. + + The claim moves every run or none, so when another submitter claimed + one of them first, its call computes that one, and the others are + claimed again without it. + """ + pending = [ + (run.id, parameters) + for run, parameters in runs + if run.status is RunStatus.PENDING + ] + while pending: + outcome = await self.dispatch.start_simulation_runs( + [run_id for run_id, _ in pending], + household_modal_request( + worker, + calculation_id=calculation_id, + inputs=inputs, + run_policies=pending, + ), + ) + if outcome is not DispatchOutcome.NOT_CLAIMED: + return + still_pending = [] + for run_id, parameters in pending: + calculation = await self.read(run_id) + if calculation is not None and calculation.run.status is ( + RunStatus.PENDING + ): + still_pending.append((run_id, parameters)) + # A claim fails only when a run is no longer pending. + if len(still_pending) == len(pending): # pragma: no cover + return + pending = still_pending + + async def _write[T](self, operation: Callable[[], T]) -> T: + """Run database writes off the event loop, naming a refused reference.""" + try: + return await asyncio.to_thread(operation) + except IntegrityError as error: + raise UnknownHouseholdReferenceError(UNKNOWN_REFERENCE_MESSAGE) from error diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py index bb5649ce1..679b00085 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -1,4 +1,4 @@ -"""The household calculation routes: submit a calculation, then poll it. +"""The household routes: submit a calculation or a report, then poll it. - ``POST /v2/household-calculations`` (permission ``simulation:submit``) records the calculation and starts its Modal call, answering 202 with the @@ -7,11 +7,17 @@ - ``GET /v2/household-calculations/{calculation_id}`` (``simulation:read``) returns it: its status, and its household once it succeeded or its error once it failed. Poll until the status is ``succeeded`` or ``failed``. +- ``POST /v2/household-reports`` (``simulation:submit``) creates a household + report, a baseline and an optional reform, and starts its first run; + ``POST /v2/household-reports/{report_id}/runs`` starts another run of it; + ``GET /v2/household-reports/{report_id}`` (``simulation:read``) returns it + with its newest run and that run's two calculations. -Both answer 503 with ``Retry-After`` when household calculations are not -configured or a dependency cannot be reached. A submission that names a -version or country without a household function, or a household, policy or -catalog version the Public API does not have, gets 400. +Every route answers 503 with ``Retry-After`` when household calculations are +not configured or a dependency cannot be reached. A submission that names a +version or country without a household function, a household, policy or +catalog version the Public API does not have, or, for a report's run, +another household or other policies than the report's, gets 400. This is v2 code: the main path's ``run_simulation`` does not use it. """ @@ -29,9 +35,13 @@ from policyengine_simulation_entry.household_api_models import ( HouseholdCalculationResponse, HouseholdCalculationSubmission, + HouseholdReportResponse, + HouseholdReportRunSubmission, + HouseholdReportSubmission, HouseholdRouteError, ) from policyengine_simulation_entry.household_calculations import ( + ReportPoliciesMismatchError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_workers import ( @@ -40,18 +50,32 @@ ) if TYPE_CHECKING: + from collections.abc import Awaitable + from fastapi import FastAPI - from policyengine_simulation_persistence import HouseholdCalculation + from policyengine_simulation_persistence import ( + HouseholdCalculation, + HouseholdReport, + ) from policyengine_simulation_entry.auth import AuthorizationDependency logger = logging.getLogger(__name__) CALCULATIONS_PATH = "/v2/household-calculations" +REPORTS_PATH = "/v2/household-reports" RETRY_AFTER_SECONDS = 10 NOT_CONFIGURED = "Household calculations are not configured." WORKERS_UNAVAILABLE = "The v2 version manifest could not be read." DATABASE_UNAVAILABLE = "The simulation database could not be reached." +REFUSED_RESPONSE = { + "description": ( + "No household function for this version and country; an unknown " + "household, policy or catalog version; or, for a report's run, another " + "household or other policies than the report's" + ), + "model": HouseholdRouteError, +} UNAVAILABLE_RESPONSE = { "description": ( "Household calculations are not configured, or a dependency could not " @@ -74,6 +98,22 @@ async def read(self, calculation_id: UUID) -> HouseholdCalculation | None: """Return the household calculation, or None if there is none.""" ... + async def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReport: + """Create a household report and start its first run.""" + ... + + async def rerun_report( + self, report_id: UUID, submission: HouseholdReportRunSubmission + ) -> HouseholdReport | None: + """Start another run of a stored report; None if there is none.""" + ... + + async def read_report(self, report_id: UUID) -> HouseholdReport | None: + """Return a household report with its newest run, or None.""" + ... + def unavailable(detail: str) -> JSONResponse: """Answer 503 with a wait before retrying.""" @@ -91,6 +131,35 @@ def refused(detail: str, status_code: int) -> JSONResponse: ) +async def guarded[T](operation: Awaitable[T]) -> T | JSONResponse: + """Await ``operation``, answering a refusal or an unreachable dependency.""" + try: + return await operation + except ( + HouseholdFunctionUnavailableError, + UnknownHouseholdReferenceError, + ReportPoliciesMismatchError, + ) as error: + return refused(str(error), status.HTTP_400_BAD_REQUEST) + except HouseholdWorkersUnavailableError: + logger.warning("household_manifest_unavailable") + return unavailable(WORKERS_UNAVAILABLE) + except SQLAlchemyError as error: + logger.warning( + "household_database_unavailable", + extra={"error_type": type(error).__name__}, + ) + return unavailable(DATABASE_UNAVAILABLE) + + +def report_response(report: HouseholdReport, status_code: int) -> JSONResponse: + """Answer with the report and one of its runs, as they stand.""" + return JSONResponse( + HouseholdReportResponse.from_report(report).model_dump(mode="json"), + status_code=status_code, + ) + + def calculation_response( calculation: HouseholdCalculation, status_code: int ) -> JSONResponse: @@ -124,16 +193,7 @@ def register_household_calculation_routes( operation_id="submit_household_calculation", status_code=status.HTTP_202_ACCEPTED, response_model=HouseholdCalculationResponse, - responses={ - 400: { - "description": ( - "No household function for this version and country, or an " - "unknown household, policy or catalog version" - ), - "model": HouseholdRouteError, - }, - 503: UNAVAILABLE_RESPONSE, - }, + responses={400: REFUSED_RESPONSE, 503: UNAVAILABLE_RESPONSE}, dependencies=[Depends(submitter)], ) async def submit_household_calculation( @@ -141,22 +201,9 @@ async def submit_household_calculation( ) -> JSONResponse: if calculations is None: return unavailable(NOT_CONFIGURED) - try: - calculation = await calculations.submit(body) - except ( - HouseholdFunctionUnavailableError, - UnknownHouseholdReferenceError, - ) as error: - return refused(str(error), status.HTTP_400_BAD_REQUEST) - except HouseholdWorkersUnavailableError: - logger.warning("household_calculation_manifest_unavailable") - return unavailable(WORKERS_UNAVAILABLE) - except SQLAlchemyError as error: - logger.warning( - "household_calculation_database_unavailable", - extra={"error_type": type(error).__name__}, - ) - return unavailable(DATABASE_UNAVAILABLE) + calculation = await guarded(calculations.submit(body)) + if isinstance(calculation, JSONResponse): + return calculation return calculation_response(calculation, status.HTTP_202_ACCEPTED) @router.get( @@ -177,16 +224,129 @@ async def submit_household_calculation( async def get_household_calculation(calculation_id: UUID) -> JSONResponse: if calculations is None: return unavailable(NOT_CONFIGURED) - try: - calculation = await calculations.read(calculation_id) - except SQLAlchemyError as error: - logger.warning( - "household_calculation_database_unavailable", - extra={"error_type": type(error).__name__}, - ) - return unavailable(DATABASE_UNAVAILABLE) + calculation = await guarded(calculations.read(calculation_id)) + if isinstance(calculation, JSONResponse): + return calculation if calculation is None: return refused("No such household calculation.", status.HTTP_404_NOT_FOUND) return calculation_response(calculation, status.HTTP_200_OK) + _add_report_routes(router, calculations, submitter=submitter, reader=reader) app.include_router(router) + + +NO_REPORT = "No such household report." +NOT_FOUND_RESPONSE = {"description": NO_REPORT, "model": HouseholdRouteError} + + +def _add_report_routes( + router: APIRouter, + calculations: HouseholdCalculations | None, + *, + submitter: AuthorizationDependency, + reader: AuthorizationDependency, +) -> None: + """Add the household report routes to ``router``.""" + for add_route in (_add_report_creation, _add_report_run): + router.include_router(add_route(calculations, submitter)) + router.include_router(_add_report_read(calculations, reader)) + + +def _add_report_creation( + calculations: HouseholdCalculations | None, submitter: AuthorizationDependency +) -> APIRouter: + report_router = APIRouter() + + @report_router.post( + REPORTS_PATH, + summary="Create a household report", + description=( + "Create a household report (the household under a baseline and an " + "optional reform, for a year) and start its first run on a catalog " + "version. Poll the report until its status is succeeded or failed." + ), + operation_id="submit_household_report", + status_code=status.HTTP_202_ACCEPTED, + response_model=HouseholdReportResponse, + responses={400: REFUSED_RESPONSE, 503: UNAVAILABLE_RESPONSE}, + dependencies=[Depends(submitter)], + ) + async def submit_household_report( + body: HouseholdReportSubmission, + ) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + report = await guarded(calculations.submit_report(body)) + if isinstance(report, JSONResponse): + return report + return report_response(report, status.HTTP_202_ACCEPTED) + + return report_router + + +def _add_report_run( + calculations: HouseholdCalculations | None, submitter: AuthorizationDependency +) -> APIRouter: + report_router = APIRouter() + + @report_router.post( + f"{REPORTS_PATH}/{{report_id}}/runs", + summary="Run a household report again", + description=( + "Start another run of a stored household report on a catalog " + "version, reusing runs that are unfinished or succeeded unless " + "forced. The answer describes the new run." + ), + operation_id="run_household_report", + status_code=status.HTTP_202_ACCEPTED, + response_model=HouseholdReportResponse, + responses={ + 400: REFUSED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(submitter)], + ) + async def run_household_report( + report_id: UUID, body: HouseholdReportRunSubmission + ) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + report = await guarded(calculations.rerun_report(report_id, body)) + if isinstance(report, JSONResponse): + return report + if report is None: + return refused(NO_REPORT, status.HTTP_404_NOT_FOUND) + return report_response(report, status.HTTP_202_ACCEPTED) + + return report_router + + +def _add_report_read( + calculations: HouseholdCalculations | None, reader: AuthorizationDependency +) -> APIRouter: + report_router = APIRouter() + + @report_router.get( + f"{REPORTS_PATH}/{{report_id}}", + summary="Get a household report", + description=( + "Return the report with its newest run, that run's status, and its " + "baseline and reform calculations." + ), + operation_id="get_household_report", + response_model=HouseholdReportResponse, + responses={404: NOT_FOUND_RESPONSE, 503: UNAVAILABLE_RESPONSE}, + dependencies=[Depends(reader)], + ) + async def get_household_report(report_id: UUID) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + report = await guarded(calculations.read_report(report_id)) + if isinstance(report, JSONResponse): + return report + if report is None: + return refused(NO_REPORT, status.HTTP_404_NOT_FOUND) + return report_response(report, status.HTTP_200_OK) + + return report_router diff --git a/projects/policyengine-simulation-entry/tests/household_fakes.py b/projects/policyengine-simulation-entry/tests/household_fakes.py index f952cdfa4..f5de92c9a 100644 --- a/projects/policyengine-simulation-entry/tests/household_fakes.py +++ b/projects/policyengine-simulation-entry/tests/household_fakes.py @@ -17,6 +17,10 @@ from policyengine_simulation_entry.household_workers import ManifestHouseholdWorkers from policyengine_simulation_persistence import ( HouseholdCalculation, + HouseholdReport, + ReportKind, + ReportRecord, + ReportRunRecord, RunStatus, SimulationKind, SimulationRecord, @@ -29,6 +33,8 @@ if TYPE_CHECKING: from policyengine_simulation_persistence import ( HouseholdOutput, + NewReport, + NewReportRun, NewSimulation, NewSimulationRun, RunTrigger, @@ -61,6 +67,8 @@ def __init__(self, transitions: FakeRunTransitions, clock: ManualClock) -> None: self.simulations: dict[UUID, SimulationRecord] = {} self.setups: dict[UUID, RunSetup] = {} self.outputs: dict[UUID, HouseholdOutput] = {} + self.reports: dict[UUID, ReportRecord] = {} + self.report_runs: list[ReportRunRecord] = [] # Households the Public API does not have: creating their simulations # breaks a foreign key. self.unknown_households: set[UUID] = set() @@ -180,6 +188,88 @@ def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: ), ) + def get_simulation(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + return self.simulations.get(simulation_id) + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Keep a new household report.""" + report = ReportRecord( + id=uuid4(), + kind=ReportKind(new_report.kind), + country=new_report.country, + baseline_simulation_id=new_report.baseline_simulation_id, + reform_simulation_id=new_report.reform_simulation_id, + year=new_report.year, + options=dict(new_report.options), + legacy_report_id=None, + created_at=self.clock(), + ) + self.reports[report.id] = report + return report + + def get_report(self, report_id: UUID) -> ReportRecord | None: + """Return the household report with this ID, or None.""" + return self.reports.get(report_id) + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Keep a report run joining two of the fake's runs.""" + baseline = self.setups[new_report_run.baseline_simulation_run_id] + report_run = ReportRunRecord( + id=uuid4(), + report_id=new_report_run.report_id, + report_kind=ReportKind.HOUSEHOLD, + tax_benefit_model_version_id=baseline.catalog_version_id, + year=baseline.year, + baseline_simulation_run_id=new_report_run.baseline_simulation_run_id, + reform_simulation_run_id=new_report_run.reform_simulation_run_id, + trigger=new_report_run.trigger, + comparison_status=None, + comparison_modal_call_id=None, + comparison_error_code=None, + comparison_error_message=None, + comparison_error_detail=None, + comparison_check_count=0, + comparison_dispatched_at=None, + comparison_next_check_at=None, + comparison_completed_at=None, + created_at=self.clock(), + updated_at=self.clock(), + ) + self.report_runs.append(report_run) + return report_run + + def read_report( + self, report_id: UUID, *, report_run_id: UUID | None = None + ) -> HouseholdReport | None: + """Return a report with a run (its newest unless named) and both runs.""" + report = self.reports.get(report_id) + if report is None: + return None + runs = [ + report_run + for report_run in self.report_runs + if report_run.report_id == report_id + and report_run_id in {None, report_run.id} + ] + if not runs and report_run_id is not None: + return None + report_run = runs[-1] if runs else None + return HouseholdReport( + report=report, + report_run=report_run, + baseline=( + None + if report_run is None + else self.read_calculation(report_run.baseline_simulation_run_id) + ), + reform=( + None + if report_run is None or report_run.reform_simulation_run_id is None + else self.read_calculation(report_run.reform_simulation_run_id) + ), + ) + def write_output(self, _connection, run, output: HouseholdOutput) -> None: """Keep a run's household, as the result writer stores it.""" self.outputs[run.id] = output diff --git a/projects/policyengine-simulation-entry/tests/household_harness.py b/projects/policyengine-simulation-entry/tests/household_harness.py index 96eb75c39..5e6efc555 100644 --- a/projects/policyengine-simulation-entry/tests/household_harness.py +++ b/projects/policyengine-simulation-entry/tests/household_harness.py @@ -110,13 +110,13 @@ def read(self, run_id: UUID): """Read a calculation.""" return asyncio.run(self.service.read(run_id)) - def report_call_ended(self, run_id: UUID) -> None: + def report_call_ended(self, *run_ids: UUID) -> None: """Deliver the call's report that it ended, as report_call_ended does.""" - call_id = self.transitions.runs[run_id].modal_call_id + call_id = self.transitions.runs[run_ids[0]].modal_call_id assert call_id is not None asyncio.run( self.recorder.record_reported_call( - CallEndedReport(modal_call_id=call_id, simulation_run_ids=(run_id,)) + CallEndedReport(modal_call_id=call_id, simulation_run_ids=run_ids) ) ) diff --git a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py new file mode 100644 index 000000000..2ed08d6ad --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py @@ -0,0 +1,161 @@ +"""The household report routes: create a report, run it again, read it.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from conftest import FakeBackend, make_settings +from fastapi.testclient import TestClient +from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION +from household_harness import CTC_REFORM, HOUSEHOLD_ID, POLICY_ID, US_HOUSEHOLD, Harness +from household_support import calculated_household, succeeded_outcome +from policyengine_simulation_entry.app import RunServices, create_app +from policyengine_simulation_entry.modal_calls import CallSucceeded + +REPORTS_PATH = "/v2/household-reports" +REFORM = {"policy_id": str(POLICY_ID), "parameters": CTC_REFORM} + + +def report_body(**overrides: object) -> dict: + """Return the JSON body of a 2026 US report of current law against a reform.""" + body: dict[str, object] = { + "country": "us", + "catalog_version_id": str(CATALOG_VERSION_ID), + "policyengine_version": POLICYENGINE_VERSION, + "year": 2026, + "household_id": str(HOUSEHOLD_ID), + "household": US_HOUSEHOLD, + "reform": REFORM, + } + body.update(overrides) + return body + + +def rerun_body(**overrides: object) -> dict: + """Return the JSON body of another run of that report.""" + body: dict[str, object] = { + "catalog_version_id": str(CATALOG_VERSION_ID), + "policyengine_version": POLICYENGINE_VERSION, + "household_id": str(HOUSEHOLD_ID), + "household": US_HOUSEHOLD, + "reform": REFORM, + } + body.update(overrides) + return body + + +@pytest.fixture +def harness() -> Harness: + """Return a household calculation service over fakes.""" + return Harness() + + +@pytest.fixture +def report_client(harness: Harness): + """Return a client of an app serving the harness's household reports.""" + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + run_services=RunServices(household_calculations=harness.service), + ) + with TestClient(app) as test_client: + yield test_client + + +def test_a_created_report_is_accepted_with_its_first_run(report_client) -> None: + response = report_client.post(REPORTS_PATH, json=report_body()) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + assert body["country"] == "us" + assert body["year"] == 2026 + assert body["household_id"] == str(HOUSEHOLD_ID) + assert body["trigger"] == "initial" + assert body["catalog_version_id"] == str(CATALOG_VERSION_ID) + assert body["baseline"]["policy_id"] is None + assert body["reform"]["policy_id"] == str(POLICY_ID) + assert body["baseline"]["status"] == body["reform"]["status"] == "running" + + +def test_a_report_reads_back_both_households_once_its_call_ended( + report_client, harness +) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + run_ids = ( + UUID(created["baseline"]["calculation_id"]), + UUID(created["reform"]["calculation_id"]), + ) + call_id = harness.transitions.runs[run_ids[0]].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(succeeded_outcome(run_ids)) + harness.report_call_ended(*run_ids) + + response = report_client.get(f"{REPORTS_PATH}/{created['report_id']}") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "succeeded" + assert body["report_run_id"] == created["report_run_id"] + assert body["baseline"]["result"]["household"] == calculated_household(1000.0) + assert body["reform"]["result"]["household"] == calculated_household(2000.0) + + +def test_a_report_runs_again_on_request(report_client) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + + response = report_client.post( + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(force=True) + ) + + assert response.status_code == 202 + body = response.json() + assert body["trigger"] == "manual" + assert body["report_run_id"] != created["report_run_id"] + assert body["baseline"]["attempt"] == 2 + + +def test_a_run_with_other_policies_is_refused(report_client) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + + response = report_client.post( + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(reform=None) + ) + + assert response.status_code == 400 + assert "policies" in response.json()["detail"] + + +def test_an_unknown_report_is_not_found(report_client) -> None: + unknown = uuid4() + + assert report_client.get(f"{REPORTS_PATH}/{unknown}").status_code == 404 + assert ( + report_client.post( + f"{REPORTS_PATH}/{unknown}/runs", json=rerun_body() + ).status_code + == 404 + ) + + +def test_an_unknown_household_is_refused(report_client, harness) -> None: + harness.store.unknown_households.add(HOUSEHOLD_ID) + + response = report_client.post(REPORTS_PATH, json=report_body()) + + assert response.status_code == 400 + + +def test_without_household_calculations_the_report_routes_are_unavailable() -> None: + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + ) + with TestClient(app) as test_client: + created = test_client.post(REPORTS_PATH, json=report_body()) + rerun = test_client.post(f"{REPORTS_PATH}/{uuid4()}/runs", json=rerun_body()) + read = test_client.get(f"{REPORTS_PATH}/{uuid4()}") + + assert {created.status_code, rerun.status_code, read.status_code} == {503} diff --git a/projects/policyengine-simulation-entry/tests/test_household_reports.py b/projects/policyengine-simulation-entry/tests/test_household_reports.py new file mode 100644 index 000000000..609c0a3a0 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_reports.py @@ -0,0 +1,231 @@ +"""Household reports: a baseline and a reform run, computed by one Modal call.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION +from household_harness import ( + CTC_REFORM, + HOUSEHOLD_ID, + POLICY_ID, + US_HOUSEHOLD, + Harness, +) +from household_support import calculated_household, failed_outcome, succeeded_outcome +from policyengine_simulation_entry.household_api_models import ( + HouseholdReportRunSubmission, + HouseholdReportSubmission, + household_report_status, +) +from policyengine_simulation_entry.household_calculations import ( + ReportPoliciesMismatchError, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run +from policyengine_simulation_entry.modal_calls import CallSucceeded +from policyengine_simulation_persistence import RunStatus, RunTrigger + +if TYPE_CHECKING: + from policyengine_simulation_entry.run_dispatch import DispatchOutcome + +REFORM = {"policy_id": str(POLICY_ID), "parameters": CTC_REFORM} + + +def report_submission(**overrides: object) -> HouseholdReportSubmission: + """Return a 2026 US household report of current law against the CTC reform.""" + values: dict[str, object] = { + "country": "us", + "catalog_version_id": CATALOG_VERSION_ID, + "policyengine_version": POLICYENGINE_VERSION, + "year": 2026, + "household_id": HOUSEHOLD_ID, + "household": US_HOUSEHOLD, + "reform": REFORM, + } + values.update(overrides) + return HouseholdReportSubmission.model_validate(values) + + +def rerun_submission(**overrides: object) -> HouseholdReportRunSubmission: + """Return another run of the report on the same catalog version.""" + values: dict[str, object] = { + "catalog_version_id": CATALOG_VERSION_ID, + "policyengine_version": POLICYENGINE_VERSION, + "household_id": HOUSEHOLD_ID, + "household": US_HOUSEHOLD, + "reform": REFORM, + } + values.update(overrides) + return HouseholdReportRunSubmission.model_validate(values) + + +def submit_report(harness: Harness, **overrides: object): + """Submit a report and return it as it reads afterwards.""" + return asyncio.run(harness.service.submit_report(report_submission(**overrides))) + + +def rerun_report(harness: Harness, report_id: UUID, **overrides: object): + """Run a stored report again and return it as it reads afterwards.""" + return asyncio.run( + harness.service.rerun_report(report_id, rerun_submission(**overrides)) + ) + + +def report_runs(report) -> tuple[UUID, UUID]: + """Return a report's baseline and reform run IDs.""" + return report.baseline.run.id, report.reform.run.id + + +def test_one_call_computes_a_reports_baseline_and_reform() -> None: + harness = Harness() + + report = submit_report(harness) + + baseline_run_id, reform_run_id = report_runs(report) + assert report.report_run.reform_simulation_run_id == reform_run_id + assert report.baseline.simulation.policy_id is None + assert report.reform.simulation.policy_id == POLICY_ID + (spawned,) = harness.dispatcher.requests + assert spawned.arguments[1] == [str(baseline_run_id), str(reform_run_id)] + request = harness.sent_request() + assert request.calculation_id == report.report_run.id + assert [(policy.policy_key, policy.parameters) for policy in request.policies] == [ + (policy_key_for_run(baseline_run_id), {}), + (policy_key_for_run(reform_run_id), CTC_REFORM), + ] + assert {report.baseline.run.status, report.reform.run.status} == {RunStatus.RUNNING} + assert report.baseline.run.modal_call_id == report.reform.run.modal_call_id + + +def test_a_baseline_only_report_calculates_one_policy() -> None: + harness = Harness() + + report = submit_report(harness, reform=None) + + assert report.reform is None + assert report.report.reform_simulation_id is None + assert len(harness.sent_request().policies) == 1 + + +def test_the_shared_call_records_each_runs_household() -> None: + harness = Harness() + report = submit_report(harness) + run_ids = report_runs(report) + call_id = report.baseline.run.modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(succeeded_outcome(run_ids)) + + harness.report_call_ended(*run_ids) + + read = asyncio.run(harness.service.read_report(report.report.id)) + assert read.baseline.output.household == calculated_household(1000.0) + assert read.reform.output.household == calculated_household(2000.0) + assert household_report_status(read) is RunStatus.SUCCEEDED + + +def test_a_rejected_shared_call_fails_the_report() -> None: + harness = Harness() + report = submit_report(harness) + call_id = report.baseline.run.modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(failed_outcome()) + + harness.report_call_ended(*report_runs(report)) + + read = asyncio.run(harness.service.read_report(report.report.id)) + assert household_report_status(read) is RunStatus.FAILED + assert read.reform.run.error_detail == failed_outcome()["error"] + + +def test_a_baseline_another_calculation_computed_is_reused() -> None: + harness = Harness() + baseline = harness.submit() + call_id = harness.transitions.runs[baseline.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([baseline.run.id]) + ) + harness.report_call_ended(baseline.run.id) + + report = submit_report(harness) + + assert report.baseline.run.id == baseline.run.id + assert report.baseline.run.status is RunStatus.SUCCEEDED + request = harness.sent_request() + assert [policy.policy_key for policy in request.policies] == [ + policy_key_for_run(report.reform.run.id) + ] + assert household_report_status(report) is RunStatus.RUNNING + + +class ClaimingFirstStarter: + """Another submitter claims the baseline just before this one claims both.""" + + def __init__(self, harness: Harness) -> None: + """Wrap the harness's dispatch service.""" + self.dispatch = harness.service.dispatch + self.harness = harness + self.raced = False + + async def start_simulation_runs(self, run_ids, request) -> DispatchOutcome: + """Let the other submitter claim the first run once, then start these.""" + if not self.raced: + self.raced = True + self.harness.transitions.claim_simulation_dispatch([next(iter(run_ids))]) + return await self.dispatch.start_simulation_runs(run_ids, request) + + +def test_runs_another_submitter_claimed_are_left_to_it() -> None: + harness = Harness() + harness.service.dispatch = ClaimingFirstStarter(harness) + + report = submit_report(harness) + + assert report.baseline.run.status is RunStatus.DISPATCHING + assert report.reform.run.status is RunStatus.RUNNING + (spawned,) = harness.dispatcher.requests + assert spawned.arguments[1] == [str(report.reform.run.id)] + + +def test_a_report_runs_again_reusing_its_runs_unless_forced() -> None: + harness = Harness() + report = submit_report(harness) + + again = rerun_report(harness, report.report.id) + forced = rerun_report(harness, report.report.id, force=True) + + assert report_runs(again) == report_runs(report) + assert again.report_run.trigger is RunTrigger.MANUAL + assert again.report_run.id != report.report_run.id + assert forced.baseline.run.attempt == 2 + assert forced.reform.run.attempt == 2 + assert len(harness.dispatcher.requests) == 2 + latest = asyncio.run(harness.service.read_report(report.report.id)) + assert latest.report_run.id == forced.report_run.id + + +@pytest.mark.parametrize( + "overrides", + [ + {"reform": None}, + {"reform": {"policy_id": str(uuid4()), "parameters": {}}}, + {"baseline": {"policy_id": str(POLICY_ID), "parameters": {}}}, + {"household_id": str(uuid4())}, + ], + ids=["no-reform", "other-reform", "other-baseline", "other-household"], +) +def test_a_run_must_calculate_the_reports_own_policies(overrides) -> None: + harness = Harness() + report = submit_report(harness) + + with pytest.raises(ReportPoliciesMismatchError): + rerun_report(harness, report.report.id, **overrides) + + assert len(harness.dispatcher.requests) == 1 + + +def test_an_unknown_report_has_no_runs_to_start() -> None: + harness = Harness() + + assert rerun_report(harness, uuid4()) is None + assert asyncio.run(harness.service.read_report(uuid4())) is None diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index b59511e9d..96b534022 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -27,12 +27,15 @@ class OpenAPIDocument(TypedDict): components: OpenAPIComponents -# The v2 household calculation routes, which only the entrypoint serves. +# The v2 household routes, which only the entrypoint serves. HOUSEHOLD_ROUTES = { ("POST", "/v2/household-calculations"): "submit_household_calculation", ("GET", "/v2/household-calculations/{calculation_id}"): ( "get_household_calculation" ), + ("POST", "/v2/household-reports"): "submit_household_report", + ("POST", "/v2/household-reports/{report_id}/runs"): "run_household_report", + ("GET", "/v2/household-reports/{report_id}"): "get_household_report", } # The schemas only the household routes use. HOUSEHOLD_SCHEMAS = { @@ -45,6 +48,9 @@ class OpenAPIDocument(TypedDict): "HouseholdDocument", "HouseholdGroup", "HouseholdPolicyInput", + "HouseholdReportResponse", + "HouseholdReportRunSubmission", + "HouseholdReportSubmission", "HouseholdRouteError", "RunStatus", "RunTrigger", From cf1a8a9d052751d4132ac1968e85c9e5392496d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:06:45 +0400 Subject: [PATCH 104/124] Calculate a household on every promoted staging revision After a staging deployment promotes its revision, with the v2 application deployed, the staging_household_test job submits a US household calculation to the staging stable URL, polls until it succeeds or fails within ten minutes, and reads the result back. It runs after promotion because the household function reports the call's end to the stable URL; a failure fails the deployment run, so production does not deploy. The calculation refers to rows the Public API owns, so the test reads the staging Public API's US catalog version for the deployed PolicyEngine.py version (or its current one) and stores a one-person household there; the new beta environment variable PUBLIC_API_URL names that service. The test lives in authenticated_tests and is skipped by every other run of them. The deployment guide no longer says staging does not promote its revision. Co-Authored-By: Claude Opus 5.5 --- .../workflows/simulation-deploy.reusable.yml | 41 ++++ .../DEPLOYMENT.md | 32 ++- .../policyengine-simulation-entry/README.md | 3 +- .../test_staging_household_calculation.py | 217 ++++++++++++++++++ .../tests/test_deployment_assets.py | 6 +- .../test_staging_household_deployment.py | 84 +++++++ 6 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py create mode 100644 projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 58f071810..0b204fc61 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -1027,6 +1027,47 @@ jobs: if: ${{ failure() && steps.safety_check_restore.outcome == 'failure' }} run: .github/scripts/deploy-cloud-run-simulation-safety-check.sh pause + staging_household_test: + name: Calculate a household on the promoted staging revision + # The household function reports the call's end to the stable URL, which + # serves the promoted revision, so this runs after promotion, in staging + # only, and only when this deploy deployed the v2 application. + if: >- + ${{ + !cancelled() && + inputs.deployment_environment == 'staging' && + inputs.deploy_stage12_v2 && + needs.promote_entrypoint.result == 'success' + }} + needs: [prepare, deploy_entrypoint, promote_entrypoint] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + env: + SIMULATION_STAGING_HOUSEHOLD_TEST: "1" + SIMULATION_STAGING_TEST_BASE_URL: ${{ needs.deploy_entrypoint.outputs.stable_url }} + SIMULATION_STAGING_TEST_POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + SIMULATION_STAGING_TEST_PUBLIC_API_URL: ${{ vars.PUBLIC_API_URL }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install staging-test dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - name: Submit a household calculation and read its result back + working-directory: projects/policyengine-simulation-entry + run: uv run --frozen pytest authenticated_tests/test_staging_household_calculation.py -v + finalize_routing: name: Finalize the Modal routing publication # Finish the durable journal after every gate, including failed uploads diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 58af704da..66051e1dc 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -87,9 +87,8 @@ executor). It posts to the entrypoint's main URL: the v2 deploy job reads the service's stable URL (`status.url`) with `.github/scripts/simulation-entry-main-url.sh` and passes it to `modal deploy` as `SIMULATION_ENTRY_URL`. Only the revision that serves traffic receives -these requests. `beta` does not promote its candidate yet, so in staging the -main URL reaches an older revision until it does (plan item 1.39), and -staging calls are recorded by the safety check only once it exists there. +these requests; staging and production deployments both promote the revision +they tested, so the main URL reaches it once promotion succeeds. Household calculations (`POST /v2/household-calculations`, see the README) start the v2 household functions' calls in the same Modal environment, @@ -97,6 +96,29 @@ start the v2 household functions' calls in the same Modal environment, v2 version manifest there; they need no other setting. Their outcomes are recorded like any other call's: on the call's report, or by the safety check. +### Staging household test + +After each staging deployment promotes its revision, with the v2 application +deployed, the `staging_household_test` job runs +`authenticated_tests/test_staging_household_calculation.py` against the +staging stable URL: it submits a US household calculation, polls until it +succeeds or fails within ten minutes, and reads the result back. It runs +after promotion because the household function reports the call's end to the +stable URL. A failure fails the deployment run, so production does not +deploy. + +The calculation refers to rows the Public API owns in the same database, so +the test first reads the staging Public API's US catalog version for the +deployed PolicyEngine.py version (or its current one, when the Public API +has not published that version yet) and stores a one-person household there +(`POST /v2/households`, which returns the same household every time). The +`beta` environment variable `PUBLIC_API_URL` names the staging Public API's +URL. The test authenticates as the deployed-test client +(`SIMULATION_ENTRYPOINT_TEST_AUTH_*`), which needs `simulation:submit` and +`simulation:read` when permissions are enforced. The other runs of +`authenticated_tests` skip it: only this job sets +`SIMULATION_STAGING_HOUSEHOLD_TEST=1`. + ## Safety check A call whose report never arrives is recorded by the safety check, the Cloud @@ -127,8 +149,8 @@ A revision deployed before the safety check existed (without `SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID`) has no job code, so pointing the job at it pauses the schedule instead. -`beta` does not promote its candidate yet, so staging has no safety check -until it does (plan item 1.39). +Staging deployments promote their tested revision too, so staging has the +safety check as production does. One-time operator steps, in each environment: enable the Cloud Scheduler API; create the scheduler's service account, give it `roles/run.invoker` diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 3d7079409..5aaec1501 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -23,7 +23,8 @@ deployment checks can prove which revision a tagged or stable URL serves. Live caller/backend authentication checks are isolated in `authenticated_tests`. They qualify tagged Cloud Run candidates before an environment's stable traffic changes and verify its stable endpoint immediately after promotion, in both -staging and production. +staging and production. The staging household test there runs only in its own +job after a staging promotion (`DEPLOYMENT.md`, "Staging household test"). ## Household calculations diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py new file mode 100644 index 000000000..5b5b27a73 --- /dev/null +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py @@ -0,0 +1,217 @@ +"""Staging: calculate a household through the stable URL and read the result back. + +The deploy's staging household job runs this after promotion, with +``SIMULATION_STAGING_HOUSEHOLD_TEST=1``; every other run of the deployed tests +skips it. It runs against the stable URL because the household function +reports the call's end there, so only the promoted revision records it. + +The calculation needs rows the Public API owns in the same database: a +catalog version and a stored household. The test reads the staging Public +API's US catalog version for the deployed PolicyEngine.py version (or, when +the Public API has not published that version yet, its current one) and +stores a one-person household there, which returns the same household each +time. It then submits the calculation, forced so each deploy starts a new +Modal call, polls until it succeeds or fails within a bounded time, and +reads the result back. +""" + +from __future__ import annotations + +import os +import time +from typing import Any + +import httpx +import pytest + +STAGING_HOUSEHOLD_TEST_FLAG = "SIMULATION_STAGING_HOUSEHOLD_TEST" +pytestmark = pytest.mark.skipif( + os.environ.get(STAGING_HOUSEHOLD_TEST_FLAG) != "1", + reason="Runs only in the staging household job, after promotion.", +) + +YEAR = 2026 +POLL_INTERVAL_SECONDS = 5 +# A household container that starts cold installs nothing but loads the US +# model, about a minute; the rest allows for a queued container and for the +# call's report to be recorded. +CALCULATION_DEADLINE_SECONDS = 600 +# One adult in California, as the Public API stores it. +STORED_HOUSEHOLD = { + "people": [ + { + "id": "you", + "values": { + "age": {str(YEAR): 40}, + "employment_income": {str(YEAR): 30_000}, + }, + "memberships": { + "household": "your household", + "family": "your family", + "tax_unit": "your tax unit", + "spm_unit": "your spm unit", + "marital_unit": "your marital unit", + }, + } + ], + "household": [ + {"id": "your household", "values": {"state_name": {str(YEAR): "CA"}}} + ], + "family": [{"id": "your family", "values": {}}], + "tax_unit": [{"id": "your tax unit", "values": {}}], + "spm_unit": [{"id": "your spm unit", "values": {}}], + "marital_unit": [{"id": "your marital unit", "values": {}}], +} +# The same household in the situation shape the household functions take, +# asking for its net income. +SITUATION = { + "people": { + "you": {"age": {str(YEAR): 40}, "employment_income": {str(YEAR): 30_000}} + }, + "families": {"your family": {"members": ["you"]}}, + "tax_units": {"your tax unit": {"members": ["you"]}}, + "spm_units": {"your spm unit": {"members": ["you"]}}, + "marital_units": {"your marital unit": {"members": ["you"]}}, + "households": { + "your household": { + "members": ["you"], + "state_name": {str(YEAR): "CA"}, + "household_net_income": {str(YEAR): None}, + } + }, +} + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + msg = f"{name} is required for the staging household test." + raise RuntimeError(msg) + return value + + +@pytest.fixture(scope="session") +def base_url() -> str: + """Return the staging stable URL, without a trailing slash.""" + return _required("SIMULATION_STAGING_TEST_BASE_URL").rstrip("/") + + +@pytest.fixture(scope="session") +def public_api_url() -> str: + """Return the staging Public API's URL, without a trailing slash.""" + return _required("SIMULATION_STAGING_TEST_PUBLIC_API_URL").rstrip("/") + + +@pytest.fixture(scope="session") +def authorization() -> dict[str, str]: + """Return a bearer header with a client-credentials token for the service.""" + issuer = _required("SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER").rstrip("/") + response = httpx.post( + f"{issuer}/oauth/token", + json={ + "client_id": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID"), + "client_secret": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET"), + "audience": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE"), + "grant_type": "client_credentials", + }, + timeout=30, + ) + response.raise_for_status() + token = response.json().get("access_token") + if not isinstance(token, str) or not token: + msg = "Auth0 response omitted access_token." + raise RuntimeError(msg) + return {"Authorization": f"Bearer {token}"} + + +def catalog_version(public_api_url: str, policyengine_version: str) -> dict[str, str]: + """Return the US catalog version for the deployed version, or the current one.""" + url = f"{public_api_url}/v2/tax-benefit-models/by-country/us" + response = httpx.get( + url, params={"policyengine_version": policyengine_version}, timeout=30 + ) + if response.status_code == httpx.codes.NOT_FOUND: + response = httpx.get(url, timeout=30) + response.raise_for_status() + result = response.json()["result"] + return { + "catalog_version_id": result["model_version"]["id"], + "policyengine_version": result["policyengine_version"], + } + + +def stored_household_id(public_api_url: str) -> str: + """Store the household in the Public API, or find it, and return its ID.""" + response = httpx.post( + f"{public_api_url}/v2/households", + params={"country_id": "us"}, + json={ + "country_id": "us", + "default_year": YEAR, + "household_data": STORED_HOUSEHOLD, + }, + timeout=30, + ) + assert response.status_code in {httpx.codes.OK, httpx.codes.CREATED}, response.text + return response.json()["result"]["item"]["id"] + + +def poll_until_finished( + base_url: str, calculation_id: str, authorization: dict[str, str] +) -> dict[str, Any]: + """Poll the calculation until it succeeds or fails, within the deadline.""" + deadline = time.monotonic() + CALCULATION_DEADLINE_SECONDS + while True: + response = httpx.get( + f"{base_url}/v2/household-calculations/{calculation_id}", + headers=authorization, + timeout=30, + ) + assert response.status_code == httpx.codes.OK, response.text + calculation = response.json() + if calculation["status"] in {"succeeded", "failed"}: + return calculation + assert time.monotonic() < deadline, ( + f"calculation {calculation_id} still {calculation['status']} after " + f"{CALCULATION_DEADLINE_SECONDS} seconds" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + +def test_a_household_calculation_succeeds_and_reads_back( + base_url: str, public_api_url: str, authorization: dict[str, str] +) -> None: + catalog = catalog_version( + public_api_url, _required("SIMULATION_STAGING_TEST_POLICYENGINE_VERSION") + ) + submitted = httpx.post( + f"{base_url}/v2/household-calculations", + json={ + "country": "us", + **catalog, + "year": YEAR, + "household_id": stored_household_id(public_api_url), + "household": SITUATION, + "force": True, + }, + headers=authorization, + timeout=60, + ) + assert submitted.status_code == httpx.codes.ACCEPTED, submitted.text + calculation_id = submitted.json()["calculation_id"] + + finished = poll_until_finished(base_url, calculation_id, authorization) + + assert finished["status"] == "succeeded", finished["error"] + result = finished["result"] + household = result["household"]["households"]["your household"] + net_income = household["household_net_income"][str(YEAR)] + assert isinstance(net_income, int | float) + assert net_income > 0 + assert result["bundle"]["policyengine_version"] == catalog["policyengine_version"] + read_back = httpx.get( + f"{base_url}/v2/household-calculations/{calculation_id}", + headers=authorization, + timeout=30, + ) + assert read_back.json() == finished diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index 5e7fcdf30..d1906bca4 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -210,9 +210,9 @@ def _assert_beta_then_prod_release( def _assert_deployment_environment_settings(reusable_workflow: str) -> None: assert ( - # Sentinel, restore, final routing acknowledgement and the simulation - # database migration use secrets too. - reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 16 + # Sentinel, restore, final routing acknowledgement, the simulation + # database migration and the staging household test use secrets too. + reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 17 ) assert "APP_ENVIRONMENT: ${{ inputs.deployment_environment }}" in reusable_workflow assert "STAGE12_ENABLED_VALUE: ${{ vars.STAGE12_ENABLED }}" in reusable_workflow diff --git a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py new file mode 100644 index 000000000..634a50368 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py @@ -0,0 +1,84 @@ +"""The staging deploy calculates a household on the promoted revision. + +The household function reports a call's end to the stable URL, which serves +the promoted revision, so the staging household job runs after promotion, +only in staging, and only when the deploy also deployed the v2 application. +""" + +from __future__ import annotations + +from test_deployment_assets_support import REUSABLE_DEPLOY_WORKFLOW, workflow_jobs + +JOB_NAME = "staging_household_test" +TEST_FILE = "authenticated_tests/test_staging_household_calculation.py" + + +def _job() -> dict: + return workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)[JOB_NAME] + + +def test_the_job_runs_after_a_successful_staging_promotion_of_the_v2_path() -> None: + job = _job() + condition = " ".join(job["if"].split()) + + assert "promote_entrypoint" in job["needs"] + assert "needs.promote_entrypoint.result == 'success'" in condition + assert "inputs.deployment_environment == 'staging'" in condition + assert "inputs.deploy_stage12_v2" in condition + assert "!cancelled()" in condition + assert job["environment"] == "${{ inputs.release_environment }}" + + +def test_the_job_targets_the_stable_url_with_the_deployed_version() -> None: + environment = _job()["env"] + + assert environment["SIMULATION_STAGING_HOUSEHOLD_TEST"] == "1" + assert environment["SIMULATION_STAGING_TEST_BASE_URL"] == ( + "${{ needs.deploy_entrypoint.outputs.stable_url }}" + ) + assert environment["SIMULATION_STAGING_TEST_POLICYENGINE_VERSION"] == ( + "${{ needs.prepare.outputs.policyengine_version }}" + ) + assert environment["SIMULATION_STAGING_TEST_PUBLIC_API_URL"] == ( + "${{ vars.PUBLIC_API_URL }}" + ) + assert {"deploy_entrypoint", "prepare"} <= set(_job()["needs"]) + + +def test_the_job_authenticates_as_the_deployed_test_client() -> None: + environment = _job()["env"] + + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}" + ) + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}" + ) + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }}" + ) + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }}" + ) + + +def test_the_job_runs_only_the_staging_household_test() -> None: + commands = [step.get("run", "") for step in _job()["steps"]] + + assert f"uv run --frozen pytest {TEST_FILE} -v" in commands + assert all("\n" not in command.strip() for command in commands) + + +def test_other_runs_of_the_deployed_tests_skip_the_household_test() -> None: + jobs = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW) + flagged = [ + name + for name, job in jobs.items() + if "SIMULATION_STAGING_HOUSEHOLD_TEST" in job.get("env", {}) + or any( + "SIMULATION_STAGING_HOUSEHOLD_TEST" in step.get("env", {}) + for step in job.get("steps", []) + ) + ] + + assert flagged == [JOB_NAME] From 78b15114b8723a3554ae535e6c7f1789e3c45f8b Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:24:12 +0400 Subject: [PATCH 105/124] Fail household runs at once when their call's outcome lacks one of them A household outcome with no result for one of its call's runs used to raise a plain ValueError inside the success transaction, which run recording took for a database failure: nothing was recorded, the report was answered as retry-later, and every safety check read the same output again until Modal no longer held it. The writer now raises CalculationFailedError, and run recording fails the runs with invalid_result, as it does for an output that does not validate. Co-Authored-By: Claude Opus 5.5 --- .../household_recording.py | 14 +++++++--- .../run_recording.py | 10 ++++--- .../tests/test_household_recording.py | 26 ++++++++++++++++++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py index 48492f855..bc868ca04 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py @@ -109,8 +109,9 @@ def write( ) -> None: """Write each run's household in the transaction that succeeds them. - Raise ValueError, writing nothing, when the outcome has no result for - one of the runs. + Raise CalculationFailedError, writing nothing, when the outcome has no + result for one of the runs: the output cannot describe those runs, so + they fail as an invalid result instead of waiting for it to change. """ if not isinstance(result, HouseholdExecutionResult): msg = "a household result recorder writes household results only" @@ -123,8 +124,13 @@ def write( for run in runs: policy_result = by_policy_key.get(policy_key_for_run(run.id)) if policy_result is None: - msg = f"the household outcome has no result for run {run.id}" - raise ValueError(msg) + raise CalculationFailedError( + RunError( + RunErrorCode.INVALID_RESULT, + "The household outcome has no result for one of the " + "call's runs.", + ) + ) outputs.append((run, _household_output(result, policy_result))) for run, output in outputs: self._write_output(connection, run, output) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py index 6bd072835..a1ff30382 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -87,11 +87,12 @@ def _event_name(prefix: str, outcome: str) -> str: class CalculationFailedError(Exception): - """A finished call's output reports that its calculation failed. + """A finished call's output says its runs failed; record that failure. A result recorder's ``validate`` raises it for a valid failure document, - such as a household function's typed failure; the runs are then failed - with ``error``, its detail included, rather than as an invalid result. + such as a household function's typed failure, and its ``write`` for an + output that cannot describe the runs it is written for. Nothing is + written, and the runs are failed with ``error``, its detail included. """ def __init__(self, error: RunError) -> None: @@ -559,6 +560,9 @@ async def _record_result( return await self._fail(handlers, _invalid_result(error)) try: completed = await asyncio.to_thread(steps.complete, result) + # The transaction rolled back; the output cannot describe the runs. + except CalculationFailedError as failure: + return await self._fail(handlers, failure.error) # Writing the result rows failed, for example because the database # was unreachable: nothing was recorded, and the call is checked again. except Exception as error: diff --git a/projects/policyengine-simulation-entry/tests/test_household_recording.py b/projects/policyengine-simulation-entry/tests/test_household_recording.py index 78da18017..fed86a114 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_recording.py +++ b/projects/policyengine-simulation-entry/tests/test_household_recording.py @@ -109,11 +109,12 @@ def test_nothing_is_written_when_the_output_misses_one_of_the_runs() -> None: covered, missing = uuid4(), uuid4() result = recorder.validate(succeeded_outcome([covered])) - with pytest.raises(ValueError, match="no result for run"): + with pytest.raises(CalculationFailedError) as raised: recorder.write( object(), (household_run(covered), household_run(missing)), result ) + assert raised.value.error.code is RunErrorCode.INVALID_RESULT assert capture.written == {} @@ -195,3 +196,26 @@ def test_the_database_recorder_records_household_calls() -> None: assert isinstance( recorders.simulations[SimulationKind.HOUSEHOLD], HouseholdResultRecorder ) + + +def test_an_outcome_missing_one_of_the_calls_runs_fails_them_at_once() -> None: + """Such an output is invalid for these runs; waiting would never fix it.""" + capture = OutputCapture() + recorder, transitions, inspector = _recorder(capture) + run_ids = transitions.running_call("fc-household", "household", "household") + inspector.outcomes["fc-household"] = CallSucceeded(succeeded_outcome(run_ids[:1])) + + status = asyncio.run( + recorder.record_reported_call( + CallEndedReport( + modal_call_id="fc-household", simulation_run_ids=tuple(run_ids) + ) + ) + ) + + assert status is CallReportStatus.RECORDED + for run_id in run_ids: + run = transitions.runs[run_id] + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.INVALID_RESULT + assert capture.written == {} From c875d6bbdb1ee031101f3d72e1e85bc6cb5f7b00 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:25:29 +0400 Subject: [PATCH 106/124] Read a saved household's null SPM selection as no choice The household routes passed an explicit "spm": null to the household function as a choice, labelled as the saved household's, which the function rejects for a US household. The household functions' contract reads a saved null as no choice, so the request now leaves it out and the bundle's certified defaults apply, as when the field is omitted. Co-Authored-By: Claude Opus 5.5 --- .../household_api_models.py | 12 ++++++++---- .../tests/test_household_calculations.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py index 97e59f4b5..e794b424b 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -113,8 +113,8 @@ class StoredHouseholdInputs(StrictRouteModel): ``policyengine_version`` the PolicyEngine.py version it was published from, which picks the household function. ``household`` is the stored household in the situation shape, without axes (they are an option); - ``spm`` is its saved SPM choice, omitted when it has none, so the - bundle's certified defaults apply. + ``spm`` is its saved SPM choice, omitted or null when it has none, so + the bundle's certified defaults apply. """ catalog_version_id: UUID @@ -136,8 +136,12 @@ def refuse_axes_in_the_household( @property def spm_requested(self) -> bool: - """Whether the household has a saved SPM choice to calculate with.""" - return "spm" in self.model_fields_set + """Whether the household has a saved SPM choice to calculate with. + + A saved household's null selection is no choice, as the household + functions' contract reads it. + """ + return "spm" in self.model_fields_set and self.spm is not None class HouseholdCalculationSubmission(StoredHouseholdInputs): diff --git a/projects/policyengine-simulation-entry/tests/test_household_calculations.py b/projects/policyengine-simulation-entry/tests/test_household_calculations.py index ef124f782..70b207037 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_calculations.py +++ b/projects/policyengine-simulation-entry/tests/test_household_calculations.py @@ -226,3 +226,13 @@ def test_a_calculation_left_dispatching_fails_and_runs_again() -> None: assert resubmitted.run.id != stopped_run_id assert resubmitted.run.attempt == 2 assert resubmitted.run.status is RunStatus.RUNNING + + +def test_a_saved_null_spm_choice_is_no_choice() -> None: + """A stored household's null selection inherits the defaults, as omission does.""" + harness = Harness() + + harness.submit(spm=None) + + assert "spm" not in harness.dispatcher.requests[0].arguments[0] + assert not harness.sent_request().spm_requested From b166568a110711aab0f19b7abbb6c8f173d72df3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:27:12 +0400 Subject: [PATCH 107/124] Start a household report's runs before creating the report Creating a household report inserted the report before starting its runs, so a catalog version the database refused answered 400 but left a stored report with no run, whose ID the caller never received. The runs now start first, and the report and its report run are created only once they exist. Co-Authored-By: Claude Opus 5.5 --- .../household_calculations.py | 120 +++++++++++------- .../tests/household_fakes.py | 8 ++ .../tests/test_household_reports.py | 12 ++ 3 files changed, 93 insertions(+), 47 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index 040ca8db7..2a6d3000a 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -324,6 +324,8 @@ async def submit_report( ) -> HouseholdReport: """Create a household report and start its first run. + The runs start before the report is created, so a household, policy + or catalog version the database refuses leaves no report behind. Raise as ``submit`` does. """ worker = await asyncio.to_thread( @@ -333,13 +335,19 @@ async def submit_report( [] if submission.reform is None else [submission.reform] ) - def create() -> tuple[ReportRecord, list[SimulationRecord]]: + def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: simulations = [ self.store.find_or_create_simulation( submission.simulation_definition(policy) ) for policy in policies ] + runs = self._start_side_runs( + simulations, + catalog_version_id=submission.catalog_version_id, + year=submission.year, + submission=submission, + ) report = self.store.create_report( NewReport( kind=ReportKind.HOUSEHOLD, @@ -351,15 +359,15 @@ def create() -> tuple[ReportRecord, list[SimulationRecord]]: year=submission.year, ) ) - return report, simulations + return report, runs - report, simulations = await self._write(create) - return await self._run_report( + report, runs = await self._write(create) + return await self._join_and_start( worker, report, inputs=submission, - sides=list(zip(simulations, policies, strict=True)), - submission=submission, + sides=list(zip(runs, policies, strict=True)), + trigger=submission.trigger, ) async def rerun_report( @@ -395,68 +403,86 @@ def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: policies = [submission.baseline] + ( [] if submission.reform is None else [submission.reform] ) - sides = list(zip(simulations, policies, strict=False)) - _check_report_sides(report, sides, submission) + _check_report_sides( + report, list(zip(simulations, policies, strict=False)), submission + ) worker = await asyncio.to_thread( self.workers.resolve, report.country, submission.policyengine_version ) - inputs = ReportRunInputs( - country=report.country, - household=submission.household, - spm=submission.spm, - spm_requested=submission.spm_requested, - options=HouseholdCalculationOptions.model_validate(simulations[0].options), + runs = await self._write( + lambda: self._start_side_runs( + simulations, + catalog_version_id=submission.catalog_version_id, + year=report.year, + submission=submission, + ) ) - return await self._run_report( - worker, report, inputs=inputs, sides=sides, submission=submission + return await self._join_and_start( + worker, + report, + inputs=ReportRunInputs( + country=report.country, + household=submission.household, + spm=submission.spm, + spm_requested=submission.spm_requested, + options=HouseholdCalculationOptions.model_validate( + simulations[0].options + ), + ), + sides=list(zip(runs, policies, strict=True)), + trigger=submission.trigger, ) async def read_report(self, report_id: UUID) -> HouseholdReport | None: """Return a household report with its newest run, or None.""" return await asyncio.to_thread(self.store.read_report, report_id) - async def _run_report( + def _start_side_runs( + self, + simulations: Sequence[SimulationRecord], + *, + catalog_version_id: UUID, + year: int, + submission: HouseholdReportSubmission | HouseholdReportRunSubmission, + ) -> list[SimulationRunRecord]: + """Start or reuse a run of each side; a simulation on both sides runs once.""" + started: dict[UUID, SimulationRunRecord] = {} + for simulation in simulations: + if simulation.id not in started: + started[simulation.id] = self.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=catalog_version_id, + year=year, + trigger=submission.trigger, + ), + force=submission.force, + ).run + return [started[simulation.id] for simulation in simulations] + + async def _join_and_start( self, worker: HouseholdWorker, report: ReportRecord, *, inputs: HouseholdInputs, - sides: ReportSides, - submission: HouseholdReportSubmission | HouseholdReportRunSubmission, + sides: Sequence[tuple[SimulationRunRecord, HouseholdPolicyInput]], + trigger: RunTrigger, ) -> HouseholdReport: - """Start or reuse a run of each side, join them, and start the pending.""" - trigger: RunTrigger = submission.trigger - - def start() -> tuple[list[SimulationRunRecord], ReportRunRecord]: - # A report comparing a simulation with itself runs it once. - started: dict[UUID, SimulationRunRecord] = {} - for simulation, _ in sides: - if simulation.id not in started: - started[simulation.id] = self.store.start_run( - NewSimulationRun( - simulation_id=simulation.id, - tax_benefit_model_version_id=submission.catalog_version_id, - year=report.year, - trigger=trigger, - ), - force=submission.force, - ).run - runs = [started[simulation.id] for simulation, _ in sides] - report_run = self.store.create_report_run( + """Join the sides' runs in a report run, and start those still pending.""" + report_run = await self._write( + lambda: self.store.create_report_run( NewReportRun( report_id=report.id, - baseline_simulation_run_id=runs[0].id, - reform_simulation_run_id=runs[1].id if len(runs) > 1 else None, + baseline_simulation_run_id=sides[0][0].id, + reform_simulation_run_id=( + sides[1][0].id if len(sides) > 1 else None + ), trigger=trigger, ) ) - return runs, report_run - - runs, report_run = await self._write(start) - unique_runs = { - run.id: (run, policy.parameters) - for run, (_, policy) in zip(runs, sides, strict=True) - } + ) + unique_runs = {run.id: (run, policy.parameters) for run, policy in sides} await self._start_pending_runs( worker, calculation_id=report_run.id, diff --git a/projects/policyengine-simulation-entry/tests/household_fakes.py b/projects/policyengine-simulation-entry/tests/household_fakes.py index f5de92c9a..ab0d1d646 100644 --- a/projects/policyengine-simulation-entry/tests/household_fakes.py +++ b/projects/policyengine-simulation-entry/tests/household_fakes.py @@ -72,6 +72,9 @@ def __init__(self, transitions: FakeRunTransitions, clock: ManualClock) -> None: # Households the Public API does not have: creating their simulations # breaks a foreign key. self.unknown_households: set[UUID] = set() + # Catalog versions the Public API does not have: starting a run on one + # breaks a foreign key. + self.unknown_catalog_versions: set[UUID] = set() def find_or_create_simulation( self, new_simulation: NewSimulation @@ -117,6 +120,11 @@ def start_run( self, new_run: NewSimulationRun, *, force: bool = False ) -> StartedSimulationRun: """Reuse the newest attempt for this setup, or add the next one.""" + if new_run.tax_benefit_model_version_id in self.unknown_catalog_versions: + statement = "INSERT INTO simulation.simulation_runs" + raise IntegrityError( + statement, {}, LookupError(new_run.tax_benefit_model_version_id) + ) attempts = [ run_id for run_id, setup in self.setups.items() diff --git a/projects/policyengine-simulation-entry/tests/test_household_reports.py b/projects/policyengine-simulation-entry/tests/test_household_reports.py index 609c0a3a0..58ce3695e 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_reports.py +++ b/projects/policyengine-simulation-entry/tests/test_household_reports.py @@ -23,6 +23,7 @@ ) from policyengine_simulation_entry.household_calculations import ( ReportPoliciesMismatchError, + UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_recording import policy_key_for_run from policyengine_simulation_entry.modal_calls import CallSucceeded @@ -229,3 +230,14 @@ def test_an_unknown_report_has_no_runs_to_start() -> None: assert rerun_report(harness, uuid4()) is None assert asyncio.run(harness.service.read_report(uuid4())) is None + + +def test_an_unknown_catalog_version_leaves_no_report_behind() -> None: + harness = Harness() + harness.store.unknown_catalog_versions.add(CATALOG_VERSION_ID) + + with pytest.raises(UnknownHouseholdReferenceError): + submit_report(harness) + + assert harness.store.reports == {} + assert harness.dispatcher.requests == [] From ff1bd3f6b014aee89aaf27b8f32505040dac6b12 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:29:30 +0400 Subject: [PATCH 108/124] Correct the household path's documentation run_dispatch no longer says no route starts calls. The household modules name the v1 run_simulation code directly instead of "the main path", which modal_calls uses for the entrypoint's own run path. The calculation request says what year means (the household's calculation year, part of the run's identity, while the household's own periods decide what is calculated) and that its PolicyEngine.py version must be the catalog version's. The deployment guide says that a v2 application deployed before the household functions took run IDs refuses them until it is redeployed. Co-Authored-By: Claude Opus 5.5 --- projects/policyengine-simulation-entry/DEPLOYMENT.md | 5 +++++ .../household_api_models.py | 10 +++++++--- .../household_calculations.py | 2 +- .../household_recording.py | 2 +- .../policyengine_simulation_entry/household_routes.py | 2 +- .../policyengine_simulation_entry/household_workers.py | 2 +- .../src/policyengine_simulation_entry/run_dispatch.py | 3 ++- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 66051e1dc..b071767da 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -95,6 +95,11 @@ start the v2 household functions' calls in the same Modal environment, `SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`, with the same token, and read the v2 version manifest there; they need no other setting. Their outcomes are recorded like any other call's: on the call's report, or by the safety check. +The household functions take the runs a call computes as their second +argument. A v2 application deployed before they did (for an older +PolicyEngine.py version) refuses that argument, so its calls fail as worker +errors, recorded by the safety check; redeploying that version's v2 +application fixes it. ### Staging household test diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py index e794b424b..98e4ea217 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -6,7 +6,7 @@ Entrypoint records the calculation against the Public API's IDs (household, policy, catalog version), whose rows the database checks exist. -This is v2 code: the main path's ``run_simulation`` does not use it. +This is v2 code: the v1 ``run_simulation`` code does not use it. """ from __future__ import annotations @@ -147,8 +147,12 @@ def spm_requested(self) -> bool: class HouseholdCalculationSubmission(StoredHouseholdInputs): """One household under one policy, to calculate for a catalog version and year. - A calculation submitted again reuses its run while it is unfinished or - once it succeeded, unless ``force``. + ``year`` is the household's calculation year, part of what identifies + the run; the household's own periods decide what is calculated, so the + caller sends the year those periods name. ``policyengine_version`` must + be the catalog version's: the Simulation Entrypoint cannot read the + catalog to check it. A calculation submitted again reuses its run while + it is unfinished or once it succeeded, unless ``force``. """ country: CountryId diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index 2a6d3000a..2b11d4002 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -27,7 +27,7 @@ together. When another submitter claimed one of them first, it starts that one's call, and this submitter starts a call for the rest. -This is v2 code: the main path's ``run_simulation`` does not use it. +This is v2 code: the v1 ``run_simulation`` code does not use it. """ from __future__ import annotations diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py index bc868ca04..8186185c1 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py @@ -14,7 +14,7 @@ and the household error document is the run's error detail, which the calculation's reader returns. -This is v2 code: the main path's ``run_simulation`` does not use it. +This is v2 code: the v1 ``run_simulation`` code does not use it. """ from __future__ import annotations diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py index 679b00085..e0129f195 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -19,7 +19,7 @@ catalog version the Public API does not have, or, for a report's run, another household or other policies than the report's, gets 400. -This is v2 code: the main path's ``run_simulation`` does not use it. +This is v2 code: the v1 ``run_simulation`` code does not use it. """ from __future__ import annotations diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py index cae5be73d..0a14c867c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py @@ -7,7 +7,7 @@ bundle it runs. The calculation's request carries that bundle, and the household function refuses any other. -This is v2 code: the main path's ``run_simulation`` does not use it. +This is v2 code: the v1 ``run_simulation`` code does not use it. """ from __future__ import annotations diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py index acdc426a6..da485ba11 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -10,7 +10,8 @@ it ends); or, when starting the call raised, fail them. If the instance stops between 1 and 3, the runs stay ``dispatching`` until the -safety check fails them as abandoned. No route starts calls yet. +safety check fails them as abandoned. The household calculation routes start +calls this way (``household_calculations``). """ from __future__ import annotations From 6c6689e9376cbca5642371b2f541c99fe8c3136c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 20:30:56 +0400 Subject: [PATCH 109/124] Reuse a succeeded household run in the tests' store only within 28 days The in-memory store the household tests use reused any run that had not failed, while RunStore reuses a succeeded run only if it finished within the reuse window. It now applies the same window, and a test shows a calculation that succeeded longer ago runs again. Co-Authored-By: Claude Opus 5.5 --- .../tests/household_fakes.py | 24 +++++++++++++------ .../tests/test_household_calculations.py | 18 ++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/projects/policyengine-simulation-entry/tests/household_fakes.py b/projects/policyengine-simulation-entry/tests/household_fakes.py index ab0d1d646..74257509b 100644 --- a/projects/policyengine-simulation-entry/tests/household_fakes.py +++ b/projects/policyengine-simulation-entry/tests/household_fakes.py @@ -3,8 +3,8 @@ ``FakeHouseholdStore`` keeps household simulations and run setups and leaves each run's status to ``FakeRunTransitions``, so the dispatcher, the recorder and the reader all see one copy of every run. It reuses runs by the same -rules as ``RunStore``: an unfinished run, or one that succeeded, is reused -unless forced; a failed one never is. +rules as ``RunStore``: an unfinished run, or one that succeeded within the +reuse window, is reused unless forced; a failed one never is. """ from __future__ import annotations @@ -16,6 +16,7 @@ from policyengine_simulation_contract.stage12_manifest import publish_v2_manifest from policyengine_simulation_entry.household_workers import ManifestHouseholdWorkers from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, HouseholdCalculation, HouseholdReport, ReportKind, @@ -31,6 +32,8 @@ from stage12_fixtures import worker if TYPE_CHECKING: + from datetime import datetime + from policyengine_simulation_persistence import ( HouseholdOutput, NewReport, @@ -67,6 +70,8 @@ def __init__(self, transitions: FakeRunTransitions, clock: ManualClock) -> None: self.simulations: dict[UUID, SimulationRecord] = {} self.setups: dict[UUID, RunSetup] = {} self.outputs: dict[UUID, HouseholdOutput] = {} + # When each succeeded run's household was written, with its success. + self.succeeded_at: dict[UUID, datetime] = {} self.reports: dict[UUID, ReportRecord] = {} self.report_runs: list[ReportRunRecord] = [] # Households the Public API does not have: creating their simulations @@ -138,11 +143,7 @@ def start_run( latest = max( attempts, key=lambda run_id: self.setups[run_id].attempt, default=None ) - if ( - latest is not None - and not force - and self.transitions.runs[latest].status is not RunStatus.FAILED - ): + if latest is not None and not force and self._reusable(latest): return StartedSimulationRun(run=self.run_record(latest), created=False) run_id = self.transitions.add_run("household", updated_at=self.clock()) self.setups[run_id] = RunSetup( @@ -154,6 +155,14 @@ def start_run( ) return StartedSimulationRun(run=self.run_record(run_id), created=True) + def _reusable(self, run_id: UUID) -> bool: + status = self.transitions.runs[run_id].status + if status is RunStatus.SUCCEEDED: + return self.succeeded_at[run_id] >= ( + self.clock() - SUCCEEDED_RUN_REUSE_WINDOW + ) + return status is not RunStatus.FAILED + def run_record(self, run_id: UUID) -> SimulationRunRecord: """Return the run as the database would, from its fake execution.""" execution = self.transitions.runs[run_id] @@ -281,6 +290,7 @@ def read_report( def write_output(self, _connection, run, output: HouseholdOutput) -> None: """Keep a run's household, as the result writer stores it.""" self.outputs[run.id] = output + self.succeeded_at[run.id] = self.clock() def manifest_workers() -> ManifestHouseholdWorkers: diff --git a/projects/policyengine-simulation-entry/tests/test_household_calculations.py b/projects/policyengine-simulation-entry/tests/test_household_calculations.py index 70b207037..ccc28d05c 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_calculations.py +++ b/projects/policyengine-simulation-entry/tests/test_household_calculations.py @@ -32,6 +32,7 @@ ModalTarget, ) from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, NewSimulationRun, RunError, RunErrorCode, @@ -236,3 +237,20 @@ def test_a_saved_null_spm_choice_is_no_choice() -> None: assert "spm" not in harness.dispatcher.requests[0].arguments[0] assert not harness.sent_request().spm_requested + + +def test_a_calculation_that_succeeded_too_long_ago_runs_again() -> None: + """Runs are reused only while their output files are kept.""" + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([submitted.run.id]) + ) + harness.report_call_ended(submitted.run.id) + + harness.clock.advance(SUCCEEDED_RUN_REUSE_WINDOW + timedelta(days=1)) + again = harness.submit() + + assert again.run.id != submitted.run.id + assert again.run.attempt == 2 From ff76bdce67260bef54c418a92275e14444967683 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 21:07:49 +0400 Subject: [PATCH 110/124] Calculate the staging test's household as stored, and retry a busy service The staging household test stored a household without the net income it then asked for, so the succeeded run it left, reusable for 28 days, did not match the household's own content. The stored household now asks for the same value. The test also failed on the first 503, which the household routes answer while the database or the version manifest cannot be reached; it now waits the answer's Retry-After and asks again, within its ten-minute bound. Co-Authored-By: Claude Opus 5.5 --- .../test_staging_household_calculation.py | 83 +++++++++++++------ .../test_staging_household_deployment.py | 68 +++++++++++++++ 2 files changed, 127 insertions(+), 24 deletions(-) diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py index 5b5b27a73..234e3757d 100644 --- a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py @@ -10,20 +10,24 @@ API's US catalog version for the deployed PolicyEngine.py version (or, when the Public API has not published that version yet, its current one) and stores a one-person household there, which returns the same household each -time. It then submits the calculation, forced so each deploy starts a new -Modal call, polls until it succeeds or fails within a bounded time, and -reads the result back. +time. It then submits that household, as stored, forced so each deploy +starts a new Modal call, polls until it succeeds or fails within a bounded +time, and reads the result back. An answer asking to retry (503 with +``Retry-After``) is retried after its wait, within the same time bound. """ from __future__ import annotations import os import time -from typing import Any +from typing import TYPE_CHECKING, Any import httpx import pytest +if TYPE_CHECKING: + from collections.abc import Callable + STAGING_HOUSEHOLD_TEST_FLAG = "SIMULATION_STAGING_HOUSEHOLD_TEST" pytestmark = pytest.mark.skipif( os.environ.get(STAGING_HOUSEHOLD_TEST_FLAG) != "1", @@ -36,7 +40,8 @@ # model, about a minute; the rest allows for a queued container and for the # call's report to be recorded. CALCULATION_DEADLINE_SECONDS = 600 -# One adult in California, as the Public API stores it. +# One adult in California, as the Public API stores it, asking for the +# household's net income. STORED_HOUSEHOLD = { "people": [ { @@ -55,15 +60,20 @@ } ], "household": [ - {"id": "your household", "values": {"state_name": {str(YEAR): "CA"}}} + { + "id": "your household", + "values": { + "state_name": {str(YEAR): "CA"}, + "household_net_income": {str(YEAR): None}, + }, + } ], "family": [{"id": "your family", "values": {}}], "tax_unit": [{"id": "your tax unit", "values": {}}], "spm_unit": [{"id": "your spm unit", "values": {}}], "marital_unit": [{"id": "your marital unit", "values": {}}], } -# The same household in the situation shape the household functions take, -# asking for its net income. +# The same household in the situation shape the household functions take. SITUATION = { "people": { "you": {"age": {str(YEAR): 40}, "employment_income": {str(YEAR): 30_000}} @@ -156,16 +166,37 @@ def stored_household_id(public_api_url: str) -> str: return response.json()["result"]["item"]["id"] +def answer_within( + send: Callable[[], httpx.Response], deadline: float +) -> httpx.Response: + """Send until the service answers other than 503, or the deadline passes. + + A 503 asks to retry after its ``Retry-After`` wait; the last answer is + returned when the deadline would pass first. + """ + while True: + response = send() + if response.status_code != httpx.codes.SERVICE_UNAVAILABLE: + return response + wait = float(response.headers.get("retry-after", POLL_INTERVAL_SECONDS)) + if time.monotonic() + wait >= deadline: + return response + time.sleep(wait) + + def poll_until_finished( base_url: str, calculation_id: str, authorization: dict[str, str] ) -> dict[str, Any]: """Poll the calculation until it succeeds or fails, within the deadline.""" deadline = time.monotonic() + CALCULATION_DEADLINE_SECONDS while True: - response = httpx.get( - f"{base_url}/v2/household-calculations/{calculation_id}", - headers=authorization, - timeout=30, + response = answer_within( + lambda: httpx.get( + f"{base_url}/v2/household-calculations/{calculation_id}", + headers=authorization, + timeout=30, + ), + deadline, ) assert response.status_code == httpx.codes.OK, response.text calculation = response.json() @@ -184,18 +215,22 @@ def test_a_household_calculation_succeeds_and_reads_back( catalog = catalog_version( public_api_url, _required("SIMULATION_STAGING_TEST_POLICYENGINE_VERSION") ) - submitted = httpx.post( - f"{base_url}/v2/household-calculations", - json={ - "country": "us", - **catalog, - "year": YEAR, - "household_id": stored_household_id(public_api_url), - "household": SITUATION, - "force": True, - }, - headers=authorization, - timeout=60, + household_id = stored_household_id(public_api_url) + submitted = answer_within( + lambda: httpx.post( + f"{base_url}/v2/household-calculations", + json={ + "country": "us", + **catalog, + "year": YEAR, + "household_id": household_id, + "household": SITUATION, + "force": True, + }, + headers=authorization, + timeout=60, + ), + time.monotonic() + CALCULATION_DEADLINE_SECONDS, ) assert submitted.status_code == httpx.codes.ACCEPTED, submitted.text calculation_id = submitted.json()["calculation_id"] diff --git a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py index 634a50368..8e58c0ff7 100644 --- a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py @@ -7,6 +7,10 @@ from __future__ import annotations +import importlib.util +from pathlib import Path + +import httpx from test_deployment_assets_support import REUSABLE_DEPLOY_WORKFLOW, workflow_jobs JOB_NAME = "staging_household_test" @@ -82,3 +86,67 @@ def test_other_runs_of_the_deployed_tests_skip_the_household_test() -> None: ] assert flagged == [JOB_NAME] + + +def _staging_test_module(): + """Load the staging household test module, which the deployed job runs.""" + path = Path(__file__).resolve().parents[1] / TEST_FILE + specification = importlib.util.spec_from_file_location( + "staging_household_calculation", path + ) + assert specification is not None + assert specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def test_the_stored_household_requests_the_same_values_it_calculates() -> None: + """The calculation sends the stored household as stored, in its other shape.""" + module = _staging_test_module() + stored = module.STORED_HOUSEHOLD["household"][0]["values"] + calculated = module.SITUATION["households"]["your household"] + + assert stored == { + name: values for name, values in calculated.items() if name != "members" + } + + +class ScriptedAnswers: + """Answer each request with the next scripted status and body.""" + + def __init__(self, answers: list[tuple[int, dict]]) -> None: + """Keep the answers in order.""" + self.answers = list(answers) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + """Return the next answer.""" + self.requests.append(request) + status, body = self.answers.pop(0) + headers = ( + {"Retry-After": "1"} if status == httpx.codes.SERVICE_UNAVAILABLE else {} + ) + return httpx.Response(status, json=body, headers=headers) + + +def test_polling_waits_out_a_service_that_asks_to_retry(monkeypatch) -> None: + module = _staging_test_module() + answers = ScriptedAnswers( + [ + (503, {"detail": "The simulation database could not be reached."}), + (200, {"status": "running"}), + (200, {"status": "succeeded", "result": {}}), + ] + ) + waits: list[float] = [] + monkeypatch.setattr(module.time, "sleep", waits.append) + monkeypatch.setattr( + module.httpx, "get", httpx.Client(transport=httpx.MockTransport(answers)).get + ) + + finished = module.poll_until_finished("https://stable.example", "id", {}) + + assert finished["status"] == "succeeded" + assert len(answers.requests) == 3 + assert waits == [1.0, module.POLL_INTERVAL_SECONDS] From 85dcceb829386067b17e46a33b051cefec49d365 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 21:09:34 +0400 Subject: [PATCH 111/124] Say that a succeeded household run is reused for 28 days only The household routes, their request models (and so the OpenAPI schema), the household service and the entrypoint guide said a succeeded calculation is reused until forced. RunStore reuses a succeeded run only within the reuse window, so a later submission runs again under a new calculation ID; every description now says so. Co-Authored-By: Claude Opus 5.5 --- projects/policyengine-simulation-entry/README.md | 4 ++-- .../policyengine_simulation_entry/household_api_models.py | 7 ++++--- .../household_calculations.py | 7 ++++--- .../src/policyengine_simulation_entry/household_routes.py | 7 ++++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 5aaec1501..be6738e6d 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -41,8 +41,8 @@ catalog version exist. Each calculation is a simulation run of a household simulation (household, policy and options such as axes), so the same calculation submitted again -reuses its run while it is unfinished or once it succeeded, unless `force` -is set. The household function comes from the v2 version manifest for the +reuses its run while it is unfinished or for 28 days after it succeeded, +unless `force` is set; after that, it runs again under a new calculation ID. The household function comes from the v2 version manifest for the calculation's PolicyEngine.py version, in the v2 application's Modal environment (`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`). The function reports when its call ends, and the outcome is recorded then diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py index 98e4ea217..1baa52ac2 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -152,7 +152,8 @@ class HouseholdCalculationSubmission(StoredHouseholdInputs): caller sends the year those periods name. ``policyengine_version`` must be the catalog version's: the Simulation Entrypoint cannot read the catalog to check it. A calculation submitted again reuses its run while - it is unfinished or once it succeeded, unless ``force``. + it is unfinished or for 28 days after it succeeded, unless ``force``; + after that, it runs again under a new calculation ID. """ country: CountryId @@ -199,8 +200,8 @@ class HouseholdReportRunSubmission(StoredHouseholdInputs): The household and both policies must be the report's own: the Public API sends their contents again, because the Simulation Entrypoint cannot - read them. The runs are reused while unfinished or once they succeeded, - unless ``force``. + read them. The runs are reused while unfinished or for 28 days after + they succeeded, unless ``force``. """ baseline: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index 2b11d4002..8d8744728 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -7,9 +7,10 @@ 1. finds the household function the calculation's PolicyEngine.py version deployed, in the v2 version manifest, before anything is written; 2. finds or creates the simulation, then reuses its run for that catalog - version and year (while unfinished, or once it succeeded) or starts the - next attempt; the database refuses a household, policy or catalog version - the Public API does not have; + version and year (while unfinished, or for 28 days after it succeeded, + ``SUCCEEDED_RUN_REUSE_WINDOW``) or starts the next attempt; the database + refuses a household, policy or catalog version the Public API does not + have; 3. starts the run's Modal call if the run is pending, whoever created it, so a run another submitter left pending runs too. Only the submitter whose claim moves the run to ``dispatching`` starts the call diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py index e0129f195..7d136b2dd 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -3,7 +3,8 @@ - ``POST /v2/household-calculations`` (permission ``simulation:submit``) records the calculation and starts its Modal call, answering 202 with the calculation as it stands. Submitting the same calculation again returns - the same one while it is unfinished or once it succeeded, unless forced. + the same one while it is unfinished or for 28 days after it succeeded, + unless forced; after that, it runs again under a new calculation ID. - ``GET /v2/household-calculations/{calculation_id}`` (``simulation:read``) returns it: its status, and its household once it succeeded or its error once it failed. Poll until the status is ``succeeded`` or ``failed``. @@ -294,8 +295,8 @@ def _add_report_run( summary="Run a household report again", description=( "Start another run of a stored household report on a catalog " - "version, reusing runs that are unfinished or succeeded unless " - "forced. The answer describes the new run." + "version, reusing runs that are unfinished or succeeded within the " + "last 28 days unless forced. The answer describes the new run." ), operation_id="run_household_report", status_code=status.HTTP_202_ACCEPTED, From b7f5a3919148bdafd2e0df750ae3539d1bdf572f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 21:11:09 +0400 Subject: [PATCH 112/124] Check a household report's run against the report's policies directly The check that a report's run names the report's own household and policies compared the submitted policies with the report's simulations already paired with them, so the same rule was stated twice. It now compares the report's policy IDs, in order, with the submitted ones, and each simulation's household with the submitted household; the tests are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../household_calculations.py | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index 8d8744728..a2e6f08a2 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -84,9 +84,6 @@ ) from policyengine_simulation_entry.household_workers import HouseholdWorker - # Each of a report's simulations with the policy it calculates. - type ReportSides = Sequence[tuple[SimulationRecord, HouseholdPolicyInput]] - UNKNOWN_REFERENCE_MESSAGE = ( "The household, policy or catalog version does not exist for this country." ) @@ -205,24 +202,19 @@ class ReportRunInputs: options: HouseholdCalculationOptions -def _check_report_sides( - report: ReportRecord, - sides: ReportSides, - submission: HouseholdReportRunSubmission, +def _check_report_policies( + simulations: Sequence[SimulationRecord], + policies: Sequence[HouseholdPolicyInput], + household_id: UUID, ) -> None: - """Refuse a run that names another household or other policies.""" - submitted = [submission.baseline] + ( - [] if submission.reform is None else [submission.reform] - ) - if ( - len(submitted) != len(sides) - or (report.reform_simulation_id is None) != (submission.reform is None) - or any( - simulation.household_id != submission.household_id - or simulation.policy_id != policy.policy_id - for (simulation, _), policy in zip(sides, submitted, strict=False) - ) - ): + """Refuse a run whose household or policies are not the report's own. + + ``simulations`` are the report's baseline and, if it has one, reform; + ``policies`` the submitted baseline and reform, in the same order. + """ + if [simulation.policy_id for simulation in simulations] != [ + policy.policy_id for policy in policies + ] or any(simulation.household_id != household_id for simulation in simulations): raise ReportPoliciesMismatchError(MISMATCHED_REPORT_MESSAGE) @@ -404,9 +396,7 @@ def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: policies = [submission.baseline] + ( [] if submission.reform is None else [submission.reform] ) - _check_report_sides( - report, list(zip(simulations, policies, strict=False)), submission - ) + _check_report_policies(simulations, policies, submission.household_id) worker = await asyncio.to_thread( self.workers.resolve, report.country, submission.policyengine_version ) From cdce071b475549962f7d2c10ff80252cc3f90780 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 21:22:45 +0400 Subject: [PATCH 113/124] Keep the staging household test to one limit and one copy of its household The staging household test gave its submission and its polling a ten-minute limit each; both now share one limit from the start of the test. It builds the household it calculates from the household it stores, so the two shapes cannot drift apart, and says that a submission retried after it was partly written may leave a pending run that nothing reuses. Tests also cover a retry that would pass the limit and a reform sent for a report of the baseline alone. Co-Authored-By: Claude Opus 5.5 --- .../DEPLOYMENT.md | 3 +- .../policyengine-simulation-entry/README.md | 3 +- .../test_staging_household_calculation.py | 78 ++++++++++++------- .../tests/test_household_reports.py | 10 +++ .../test_staging_household_deployment.py | 42 ++++++++-- 5 files changed, 102 insertions(+), 34 deletions(-) diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index b071767da..a2250d06e 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -107,7 +107,8 @@ After each staging deployment promotes its revision, with the v2 application deployed, the `staging_household_test` job runs `authenticated_tests/test_staging_household_calculation.py` against the staging stable URL: it submits a US household calculation, polls until it -succeeds or fails within ten minutes, and reads the result back. It runs +succeeds or fails, and reads the result back, all within ten minutes; an +answer asking to retry (503) is retried after its wait within that limit. It runs after promotion because the household function reports the call's end to the stable URL. A failure fails the deployment run, so production does not deploy. diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index be6738e6d..d3cde5491 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -42,7 +42,8 @@ catalog version exist. Each calculation is a simulation run of a household simulation (household, policy and options such as axes), so the same calculation submitted again reuses its run while it is unfinished or for 28 days after it succeeded, -unless `force` is set; after that, it runs again under a new calculation ID. The household function comes from the v2 version manifest for the +unless `force` is set; after that, it runs again under a new calculation ID. +The household function comes from the v2 version manifest for the calculation's PolicyEngine.py version, in the v2 application's Modal environment (`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`). The function reports when its call ends, and the outcome is recorded then diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py index 234e3757d..3ff1b0a3a 100644 --- a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py @@ -11,9 +11,11 @@ the Public API has not published that version yet, its current one) and stores a one-person household there, which returns the same household each time. It then submits that household, as stored, forced so each deploy -starts a new Modal call, polls until it succeeds or fails within a bounded -time, and reads the result back. An answer asking to retry (503 with -``Retry-After``) is retried after its wait, within the same time bound. +starts a new Modal call, polls until it succeeds or fails, and reads the +result back, all within one ten-minute limit. An answer asking to retry (503 +with ``Retry-After``) is retried after its wait, within that limit; a +submission retried after it was partly written may leave a pending run +behind, which nothing reuses, since each retry is forced too. """ from __future__ import annotations @@ -73,25 +75,43 @@ "spm_unit": [{"id": "your spm unit", "values": {}}], "marital_unit": [{"id": "your marital unit", "values": {}}], } -# The same household in the situation shape the household functions take. -SITUATION = { - "people": { - "you": {"age": {str(YEAR): 40}, "employment_income": {str(YEAR): 30_000}} - }, - "families": {"your family": {"members": ["you"]}}, - "tax_units": {"your tax unit": {"members": ["you"]}}, - "spm_units": {"your spm unit": {"members": ["you"]}}, - "marital_units": {"your marital unit": {"members": ["you"]}}, - "households": { - "your household": { - "members": ["you"], - "state_name": {str(YEAR): "CA"}, - "household_net_income": {str(YEAR): None}, - } - }, +# The plural each stored group collection has in the situation shape. +SITUATION_COLLECTIONS = { + "household": "households", + "family": "families", + "tax_unit": "tax_units", + "spm_unit": "spm_units", + "marital_unit": "marital_units", } +def situation_from_stored(stored: dict[str, Any]) -> dict[str, Any]: + """Return a stored household in the situation shape the household functions take. + + Each record's ``id`` names it, and each group lists the people whose + memberships name it, so both shapes hold the same household. + """ + situation: dict[str, Any] = { + "people": {person["id"]: dict(person["values"]) for person in stored["people"]} + } + for collection, plural in SITUATION_COLLECTIONS.items(): + situation[plural] = { + group["id"]: { + "members": [ + person["id"] + for person in stored["people"] + if person["memberships"][collection] == group["id"] + ], + **group["values"], + } + for group in stored[collection] + } + return situation + + +SITUATION = situation_from_stored(STORED_HOUSEHOLD) + + def _required(name: str) -> str: value = os.environ.get(name, "").strip() if not value: @@ -185,10 +205,13 @@ def answer_within( def poll_until_finished( - base_url: str, calculation_id: str, authorization: dict[str, str] + base_url: str, + calculation_id: str, + authorization: dict[str, str], + *, + deadline: float, ) -> dict[str, Any]: - """Poll the calculation until it succeeds or fails, within the deadline.""" - deadline = time.monotonic() + CALCULATION_DEADLINE_SECONDS + """Poll the calculation until it succeeds or fails, before ``deadline``.""" while True: response = answer_within( lambda: httpx.get( @@ -203,8 +226,8 @@ def poll_until_finished( if calculation["status"] in {"succeeded", "failed"}: return calculation assert time.monotonic() < deadline, ( - f"calculation {calculation_id} still {calculation['status']} after " - f"{CALCULATION_DEADLINE_SECONDS} seconds" + f"calculation {calculation_id} still {calculation['status']} at the " + f"{CALCULATION_DEADLINE_SECONDS}-second limit" ) time.sleep(POLL_INTERVAL_SECONDS) @@ -212,6 +235,7 @@ def poll_until_finished( def test_a_household_calculation_succeeds_and_reads_back( base_url: str, public_api_url: str, authorization: dict[str, str] ) -> None: + deadline = time.monotonic() + CALCULATION_DEADLINE_SECONDS catalog = catalog_version( public_api_url, _required("SIMULATION_STAGING_TEST_POLICYENGINE_VERSION") ) @@ -230,12 +254,14 @@ def test_a_household_calculation_succeeds_and_reads_back( headers=authorization, timeout=60, ), - time.monotonic() + CALCULATION_DEADLINE_SECONDS, + deadline, ) assert submitted.status_code == httpx.codes.ACCEPTED, submitted.text calculation_id = submitted.json()["calculation_id"] - finished = poll_until_finished(base_url, calculation_id, authorization) + finished = poll_until_finished( + base_url, calculation_id, authorization, deadline=deadline + ) assert finished["status"] == "succeeded", finished["error"] result = finished["result"] diff --git a/projects/policyengine-simulation-entry/tests/test_household_reports.py b/projects/policyengine-simulation-entry/tests/test_household_reports.py index 58ce3695e..e5a3a6f59 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_reports.py +++ b/projects/policyengine-simulation-entry/tests/test_household_reports.py @@ -241,3 +241,13 @@ def test_an_unknown_catalog_version_leaves_no_report_behind() -> None: assert harness.store.reports == {} assert harness.dispatcher.requests == [] + + +def test_a_baseline_only_reports_run_takes_no_reform() -> None: + harness = Harness() + report = submit_report(harness, reform=None) + + with pytest.raises(ReportPoliciesMismatchError): + rerun_report(harness, report.report.id) + + assert len(harness.dispatcher.requests) == 1 diff --git a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py index 8e58c0ff7..3380a887b 100644 --- a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py @@ -11,6 +11,7 @@ from pathlib import Path import httpx +import pytest from test_deployment_assets_support import REUSABLE_DEPLOY_WORKFLOW, workflow_jobs JOB_NAME = "staging_household_test" @@ -101,14 +102,23 @@ def _staging_test_module(): return module -def test_the_stored_household_requests_the_same_values_it_calculates() -> None: +def test_the_calculated_household_is_the_stored_one_in_the_situation_shape() -> None: """The calculation sends the stored household as stored, in its other shape.""" module = _staging_test_module() - stored = module.STORED_HOUSEHOLD["household"][0]["values"] - calculated = module.SITUATION["households"]["your household"] - assert stored == { - name: values for name, values in calculated.items() if name != "members" + assert module.situation_from_stored(module.STORED_HOUSEHOLD) == { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, + "households": { + "your household": { + "members": ["you"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + } + }, + "families": {"your family": {"members": ["you"]}}, + "tax_units": {"your tax unit": {"members": ["you"]}}, + "spm_units": {"your spm unit": {"members": ["you"]}}, + "marital_units": {"your marital unit": {"members": ["you"]}}, } @@ -145,8 +155,28 @@ def test_polling_waits_out_a_service_that_asks_to_retry(monkeypatch) -> None: module.httpx, "get", httpx.Client(transport=httpx.MockTransport(answers)).get ) - finished = module.poll_until_finished("https://stable.example", "id", {}) + finished = module.poll_until_finished( + "https://stable.example", "id", {}, deadline=module.time.monotonic() + 60 + ) assert finished["status"] == "succeeded" assert len(answers.requests) == 3 assert waits == [1.0, module.POLL_INTERVAL_SECONDS] + + +def test_a_retry_that_would_pass_the_deadline_returns_the_refusal( + monkeypatch, +) -> None: + module = _staging_test_module() + answers = ScriptedAnswers([(503, {"detail": "unavailable"})]) + monkeypatch.setattr(module.time, "sleep", pytest.fail) + + response = module.answer_within( + lambda: httpx.Client(transport=httpx.MockTransport(answers)).get( + "https://stable.example/v2/household-calculations/id" + ), + deadline=module.time.monotonic() + 0.5, + ) + + assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE + assert len(answers.requests) == 1 From 2f0406e093b8dc695ab6c32f1db2dc710efca909 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 27 Oct 2026 21:23:10 +0400 Subject: [PATCH 114/124] Describe the household calculation path in the changelog entry Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 646fa9070..91c2d5bf2 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -2,6 +2,10 @@ changes: added: - Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced + - Added household calculations on v2 to the Simulation Entrypoint; POST /v2/household-calculations records one stored household under one policy (with its options, such as axes) for a catalog version and year as a simulation run and starts the v2 household function's Modal call, and GET /v2/household-calculations/{calculation_id} returns its status and, once it ended, the calculated household with its warnings, the SPM selection it applied (spm_config), its SPM receipt (spm_provenance) and the bundle, or its error; the same calculation reuses its run while unfinished or for 28 days after it succeeded, unless forced; the Public API sends the household with its saved SPM choice and the policy's parameter values + - Added household reports on v2; POST /v2/household-reports creates a report of a household under a baseline and an optional reform and starts its first run, POST /v2/household-reports/{report_id}/runs starts another, and GET /v2/household-reports/{report_id} returns the newest run with both calculations, whose pending runs one Modal call computes together + - Made the v2 household functions report their call's end to the Simulation Entrypoint as their last step, given the runs a call computes, and added a household result recorder, so each household call's outcome is recorded when it reports, or by the safety check; a household function's typed failure fails the runs with household_calculation_failed and keeps its error as the runs' error detail (revision 0005 adds simulation options, the household output's warnings, SPM documents and bundle, and error details) + - Added a staging household test that runs after each staging deployment promotes its revision: it submits a US household calculation to the stable URL and reads the result back within ten minutes; it reads the staging Public API's URL from the new beta environment variable PUBLIC_API_URL - Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput - Declared local_area_breakdown version 1 in the routing bundle manifest of apps whose policyengine.py bundle routes both UK area region types to one local-area release dataset (regional_release_kind local_area; an area_coded_national bundle, or one routing the two types to different datasets, does not count as routed), and made the gateway refuse breakdown with 400, before spawning, for any route whose manifest does not declare it, including legacy routes and routes pinned to an older version, since older apps ignore the field and would return a national result - Made a routed local-area dataset fail as it loads, before any simulation, when it lacks any of the four household columns the area outputs read (constituency_code_oa, la_code_oa, region_code_oa and source_household_id), for breakdown, constituency and local-authority runs alike, in the runtime and in the Stage 12 worker; Stage 12 copies the same columns From 9170b4192c703109096dc4d09c287670206ac83b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:26:34 +0400 Subject: [PATCH 115/124] Require the calculation year in a household request A household request now names the year it calculates, as a situation's period key (four digits), such as a stored household's default year. With add_missing_variables, the household function adds the missing variables, including the outputs it is asked to calculate, for that year. Before, it took the year from the first age period of a person named "you", or else today's date, as the Public API does; v2 households name people by record ID, so a household whose default year is not the current year was given added inputs and outputs for the wrong year. A request without a year, or with a malformed one, is refused. Contract schema version 1 changes in place: nothing deployed uses it, since no deployed v2 application has the household functions yet. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 6 +- .../household_execution.py | 47 ++++++---- .../tests/test_household_execution.py | 26 ++++++ .../household_calculation.py | 22 ++--- .../household_inputs.py | 35 +++----- .../household_validation.py | 5 +- .../tests/test_household_calculation.py | 90 +++++++++++++++---- .../tests/test_household_inputs.py | 58 ++++++------ .../tests/test_household_validation.py | 1 + .../tests/test_stage12_modal_app.py | 1 + 10 files changed, 191 insertions(+), 100 deletions(-) diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index ae9be88a0..9c26cd8ec 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -467,7 +467,11 @@ calculates one household, in the Public API's situation shape and with optional axes, under one to ten policies, so a baseline and its reform are calculated in one call. It takes a `HouseholdExecutionRequest` and returns a `HouseholdExecutionResult` or a typed `HouseholdExecutionFailure` from -`policyengine_simulation_contract.household_execution`, as JSON. The +`policyengine_simulation_contract.household_execution`, as JSON. The request +names the year it calculates, such as a stored household's default year, and +a calculate-full request adds the household's missing variables for that year; +the Public API instead takes the year from a person named `you`, or the +current year, and v2 households name people by record ID. Otherwise the calculation follows the Public API's household calculation, including its input errors, warnings and SPM receipt. A function refuses a request for another country, and returns a `bundle_mismatch` failure for a request that diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py index 254df8be1..4b8688159 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py @@ -84,6 +84,16 @@ ParameterPath = Annotated[str, Field(min_length=1, max_length=512)] ParameterPeriod = Annotated[str, Field(min_length=1, max_length=64)] ContractText = Annotated[str, Field(min_length=1, max_length=255)] +CalculationYear = Annotated[ + str, + Field( + pattern=r"^[0-9]{4}$", + description=( + "The year the household is calculated for, written as a " + "situation's period key, such as '2025'." + ), + ), +] PolicyKey = Annotated[ str, Field( @@ -464,18 +474,22 @@ class HouseholdExecutionRequest(StrictHouseholdModel): """A caller to a Modal household function: calculate one household. ``calculation_id`` is the caller's durable identity for the calculation. - ``bundle`` is the bundle the caller resolved from the version manifest; the - household function refuses a request for any other bundle. + ``year`` is the year the household is calculated for, such as a stored + household's default year, written as a situation's period key (four + digits). ``bundle`` is the bundle the caller resolved from the version + manifest; the household function refuses a request for any other bundle. ``add_missing_variables`` adds every yearly, monthly and eternal variable - the household's entities lack before calculating, as the Public API's - ``/calculate-full`` route does. ``spm`` carries the caller's SPM settings - exactly as sent, as any JSON value, and the household function checks - them with the Public API's SPM error codes: settings for a country other - than the US are unsupported, and for the US an explicit null or malformed - settings are invalid. Omit ``spm`` to inherit the bundle's certified - defaults; sending it, even as an empty object, is a choice, and then a - missing SPM input is an error rather than an uncalculated value. + the household's entities lack, for ``year``, before calculating, as the + Public API's ``/calculate-full`` route does (that route takes its year from + the first age period of a person named ``you``, or else the current year, + and v2 households name people by record ID). ``spm`` carries the caller's + SPM settings exactly as sent, as any JSON value, and the household + function checks them with the Public API's SPM error codes: settings for a + country other than the US are unsupported, and for the US an explicit null + or malformed settings are invalid. Omit ``spm`` to inherit the bundle's + certified defaults; sending it, even as an empty object, is a choice, and + then a missing SPM input is an error rather than an uncalculated value. ``spm_selection_source`` says whether the settings came from this request or from a saved household, which changes only how an SPM forecast the bundle no longer carries is reported. @@ -483,11 +497,13 @@ class HouseholdExecutionRequest(StrictHouseholdModel): To calculate a saved household with the inputs the Public API's stored-household route (``GET /{country}/household/{id}/policy/{policy_id}``) uses, set ``add_missing_variables``, because that route always adds - missing variables; pass the household's saved SPM selection as ``spm`` - only if it has one (a saved null is no choice); and set - ``spm_selection_source`` to ``"saved_household"``. The calculated - household, warnings, SPM receipt and SPM error codes then match that - route's. Its error messages do not: the route reports a household it + missing variables, and ``year`` to the year the run calculates, such as + the household's default year; pass the household's saved SPM selection as + ``spm`` only if it has one (a saved null is no choice); and set + ``spm_selection_source`` to ``"saved_household"``. When ``year`` is the + year that route would infer, the calculated household, warnings, SPM + receipt and SPM error codes then match that route's. Its error messages + do not: the route reports a household it cannot read, and any other failure except an SPM error, as an error calculating that household ID under that policy ID, while a household function reports the request routes' wording (``invalid_household`` and @@ -501,6 +517,7 @@ class HouseholdExecutionRequest(StrictHouseholdModel): schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION calculation_id: UUID country: CountryId + year: CalculationYear household: HouseholdDocument policies: Policies add_missing_variables: bool = False diff --git a/libs/policyengine-simulation-contract/tests/test_household_execution.py b/libs/policyengine-simulation-contract/tests/test_household_execution.py index ce7aac034..ef407588b 100644 --- a/libs/policyengine-simulation-contract/tests/test_household_execution.py +++ b/libs/policyengine-simulation-contract/tests/test_household_execution.py @@ -122,6 +122,7 @@ def _request(**overrides: object) -> HouseholdExecutionRequest: values: dict[str, object] = { "calculation_id": str(CALCULATION_ID), "country": "us", + "year": "2026", "household": US_SITUATION, "policies": _policies(), "bundle": _bundle().model_dump(mode="json"), @@ -285,6 +286,31 @@ def test_one_request_carries_the_baseline_and_its_reform() -> None: assert request.bundle == _bundle() +def test_a_request_names_the_year_it_calculates() -> None: + request = _request(year="2025") + + assert request.year == "2025" + assert request.model_dump(mode="json")["year"] == "2025" + assert ( + HouseholdExecutionRequest.model_validate(request.model_dump(mode="json")) + == request + ) + + +def test_a_request_without_a_year_is_refused() -> None: + values = _request().model_dump(mode="json") + del values["year"] + + with pytest.raises(ValidationError, match="year"): + HouseholdExecutionRequest.model_validate(values) + + +@pytest.mark.parametrize("year", ["25", "20255", "2025-01", "year", "", " 2025", 2025]) +def test_a_request_with_a_malformed_year_is_refused(year: object) -> None: + with pytest.raises(ValidationError, match="year"): + _request(year=year) + + def test_add_missing_variables_asks_for_a_full_calculation() -> None: assert _request(add_missing_variables=True).add_missing_variables is True diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py index 576944a2d..ac8fbe749 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -18,10 +18,10 @@ numbers and JSON scalars, and the Public API writes a bare ``NaN``, which is not JSON. A date is the HTTP date text the Public API's Flask encoder writes. -- The default household year, used when ``you`` has no age by period, is the - current UTC year; the Public API uses its host's local date. -- With ``add_missing_variables``, an age given for ``you`` as one value, not - by period, counts as no age; the Public API fails with an internal error. +- Missing variables are added for the request's ``year``. The Public API + adds them for the first period of the age of a person named ``you``, or + else its host's current year (and fails with an internal error when that + age is one value, not by period); v2 households name people by record ID. - An explicit null ``axes`` means no axes, because the contract drops it; the Public API treats the key's presence as a household with axes. - Every policy's parameters are checked before any policy is calculated, so @@ -164,13 +164,11 @@ class HouseholdCalculationDependencies: """Seams a household calculation uses; unset seams use the defaults. The defaults are the installed bundle, the process's cached country - models, today's UTC date (which sets the default household year) and no - telemetry runtime. + models and no telemetry runtime. """ installed_bundle: HouseholdBundleProvenance | None = None model_loader: Callable[[str], CountryHouseholdModel] = load_country_household_model - today: date | None = None runtime: ObservabilityRuntime | None = None @@ -569,7 +567,6 @@ def _prepared_household( request: HouseholdExecutionRequest, model: CountryHouseholdModel, *, - today: date, spm_selection: dict[str, Any] | None, ) -> _PreparedHousehold: """Prepare the household as the Public API does; raise for unknown names. @@ -580,7 +577,7 @@ def _prepared_household( """ situation = request.household.to_situation() if request.add_missing_variables: - add_missing_variables(situation, model.metadata, today=today) + add_missing_variables(situation, model.metadata, year=request.year) deprecated = drop_deprecated_inputs(situation) household_errors = find_unrecognized_household_inputs( deprecated.household, model.metadata @@ -628,12 +625,7 @@ def _calculate_output( model = dependencies.model_loader(request.country) with _stage_scope(runtime, Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, country): spm_selection = _resolved_spm_selection(request) - prepared = _prepared_household( - request, - model, - today=dependencies.today or datetime.now(UTC).date(), - spm_selection=spm_selection, - ) + prepared = _prepared_household(request, model, spm_selection=spm_selection) results = [] for policy in request.policies: attributes = {**country, "policy_key": policy.policy_key} diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py index 47a909cd6..b2b684959 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py @@ -1,11 +1,12 @@ """Household input preparation, matching the Public API's in-process calculation. These functions reproduce, for the v2 household functions, how the Public API -prepares a household before calculating it: which period missing variables -are added for, which deprecated inputs are dropped (and the warning that says -so), which names are reported as unrecognized, and which values are -requested. Keeping the wording and order identical lets a v2 result be -compared with the Public API's result field by field. +prepares a household before calculating it: which missing variables are added, +which deprecated inputs are dropped (and the warning that says so), which +names are reported as unrecognized, and which values are requested. Keeping +the wording and order identical lets a v2 result be compared with the Public +API's result field by field. Missing variables are added for the request's +year, where the Public API infers one. """ from __future__ import annotations @@ -18,7 +19,6 @@ if TYPE_CHECKING: from collections.abc import Iterable, Mapping - from datetime import date from policyengine_core.taxbenefitsystems import TaxBenefitSystem from policyengine_core.variables import Variable @@ -131,34 +131,19 @@ def _metadata_default_value(variable: Variable) -> bool | int | float | str | No return None -def household_year(situation: Mapping[str, Any], *, today: date) -> str: - """Return the period missing yearly variables are added for. - - As in the Public API, this is the first period of the age entered for the - person named ``you``, or else the current calendar year. An age entered - without periods counts as no age. - """ - people = situation.get("people") - you = people.get("you") if isinstance(people, dict) else None - ages = you.get("age") if isinstance(you, dict) else None - if isinstance(ages, dict) and ages: - return str(next(iter(ages))) - return str(today.year) - - def add_missing_variables( situation: dict[str, Any], metadata: HouseholdModelMetadata, *, - today: date, + year: str, ) -> None: """Add every yearly, monthly and eternal variable each entity lacks, in place. Input variables get their default value and calculated variables get - None, which requests them, for the household's year. This is what the - Public API's ``/calculate-full`` route adds. + None, which requests them, for ``year``, the request's calculation year. + The Public API's ``/calculate-full`` route adds the same variables, for + the first age period of a person named ``you`` or else the current year. """ - year = household_year(situation, today=today) for variable in metadata.variables.values(): if variable.definition_period not in _ADDED_DEFINITION_PERIODS: continue diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py index 76d842744..02510a0f3 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py @@ -36,8 +36,8 @@ def household_validation_request( ) -> HouseholdExecutionRequest: """Return a one-adult, current-law household request for ``country``. - The request names the installed bundle and asks for the household's net - income in ``year``. + The request names the installed bundle and ``year``, and asks for the + household's net income in that year. """ period = str(year) household = { @@ -54,6 +54,7 @@ def household_validation_request( { "calculation_id": uuid4(), "country": country, + "year": period, "household": household, "policies": [{"policy_key": "baseline", "parameters": {}}], "bundle": installed_household_bundle(country), diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py index bc9a667c3..1e632a75a 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_calculation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -8,7 +8,7 @@ from __future__ import annotations from contextlib import contextmanager -from datetime import date +from datetime import UTC, date, datetime from types import SimpleNamespace from uuid import UUID @@ -38,7 +38,6 @@ ) CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") -TODAY = date(2026, 6, 1) def _worker(country: str) -> HouseholdWorkerIdentity: @@ -52,6 +51,7 @@ def _request(country: str, household: dict, **overrides) -> HouseholdExecutionRe values = { "calculation_id": CALCULATION_ID, "country": country, + "year": "2026", "household": household, "policies": [{"policy_key": "baseline", "parameters": {}}], "bundle": installed_household_bundle(country).model_dump(mode="json"), @@ -191,7 +191,7 @@ def _calculate_with_stand_in( _request(country, household or STAND_IN_HOUSEHOLD, **overrides), worker=_worker(country), dependencies=HouseholdCalculationDependencies( - model_loader=lambda _country: model, today=TODAY + model_loader=lambda _country: model ), ) @@ -471,7 +471,7 @@ def resolve(country, selection, *, sent, saved_household): _request("us", household, spm={}, spm_selection_source=source), worker=_worker("us"), dependencies=HouseholdCalculationDependencies( - model_loader=load_model, today=TODAY, runtime=runtime + model_loader=load_model, runtime=runtime ), ) @@ -611,7 +611,7 @@ def test_us_baseline_and_reform_are_calculated_in_one_request() -> None: outcome = calculate_household( request, worker=_worker("us"), - dependencies=HouseholdCalculationDependencies(today=TODAY, runtime=runtime), + dependencies=HouseholdCalculationDependencies(runtime=runtime), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -663,7 +663,7 @@ def test_us_axes_calculate_one_value_per_point() -> None: outcome = calculate_household( _request("us", household), worker=_worker("us"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -688,7 +688,7 @@ def test_us_calculate_full_adds_and_calculates_every_missing_variable() -> None: outcome = calculate_household( _request("us", US_FAMILY, add_missing_variables=True), worker=_worker("us"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -733,7 +733,7 @@ def calculate(**spm_settings): return calculate_household( _request("us", US_SINGLE_WITHOUT_COUNTY, **spm_settings), worker=_worker("us"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) inherited = calculate() @@ -757,6 +757,64 @@ def calculate(**spm_settings): assert receipt.geography_kind == "national" +# A household named by record IDs, as v2 households are: no person is "you". +US_RECORD_HOUSEHOLD_2025 = { + "people": { + "person-1": {"age": {"2025": 35}, "employment_income": {"2025": 30_000}}, + "person-2": {"age": {"2025": 5}}, + }, + "tax_units": {"tax-unit-1": {"members": ["person-1", "person-2"]}}, + "spm_units": {"spm-unit-1": {"members": ["person-1", "person-2"]}}, + "families": {"family-1": {"members": ["person-1", "person-2"]}}, + "marital_units": { + "marital-unit-1": {"members": ["person-1"]}, + "marital-unit-2": {"members": ["person-2"]}, + }, + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "state_name": {"2025": "TX"}, + "county_fips": {"2025": "48201"}, + } + }, +} + + +def _periods(situation: dict) -> set[str]: + """Return every period key of every variable in a situation.""" + return { + str(period) + for plural, entities in situation.items() + if plural != "axes" + for variables in entities.values() + for values in variables.values() + if isinstance(values, dict) + for period in values + } + + +def test_us_calculate_full_calculates_the_request_year_not_this_year() -> None: + # The household is calculated in 2026 (or later), for its 2025 run. + assert datetime.now(UTC).year >= 2026 + + outcome = calculate_household( + _request( + "us", US_RECORD_HOUSEHOLD_2025, year="2025", add_missing_variables=True + ), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + assert _periods(situation) == {"2025"} + tax_unit = situation["tax_units"]["tax-unit-1"] + household = situation["households"]["household-1"] + assert isinstance(tax_unit["income_tax"]["2025"], float) + assert isinstance(household["household_net_income"]["2025"], float) + assert isinstance(situation["people"]["person-1"]["is_blind"]["2025"], bool) + + UK_SINGLE = { "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, "benunits": {"your benefit unit": {"members": ["you"]}}, @@ -785,7 +843,7 @@ def test_uk_axes_calculate_one_value_per_point() -> None: outcome = calculate_household( _request("uk", household), worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -804,7 +862,7 @@ def test_uk_calculate_full_adds_every_missing_yearly_variable() -> None: outcome = calculate_household( _request("uk", UK_SINGLE, add_missing_variables=True), worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -829,7 +887,7 @@ def test_uk_unrecognized_inputs_fail_with_public_api_errors() -> None: outcome = calculate_household( request, worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) # The first policy's request already fails on the household's own input, @@ -859,7 +917,7 @@ def test_uk_unrecognized_policy_parameters_name_their_policy() -> None: outcome = calculate_household( request, worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionFailure) @@ -889,7 +947,7 @@ def test_uk_reform_values_given_as_text_are_converted_to_numbers() -> None: outcome = calculate_household( request, worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -926,7 +984,7 @@ def test_uk_deprecated_inputs_warn_in_every_policy_result() -> None: outcome = calculate_household( request, worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionResult), outcome @@ -941,7 +999,7 @@ def test_uk_spm_settings_are_refused() -> None: outcome = calculate_household( _request("uk", UK_SINGLE, spm={}), worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionFailure) @@ -959,7 +1017,7 @@ def test_uk_member_who_is_not_a_person_is_an_invalid_household() -> None: outcome = calculate_household( _request("uk", household), worker=_worker("uk"), - dependencies=HouseholdCalculationDependencies(today=TODAY), + dependencies=HouseholdCalculationDependencies(), ) assert isinstance(outcome, HouseholdExecutionFailure) diff --git a/projects/policyengine-simulation-executor/tests/test_household_inputs.py b/projects/policyengine-simulation-executor/tests/test_household_inputs.py index 9f29025cf..f9c715858 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_inputs.py +++ b/projects/policyengine-simulation-executor/tests/test_household_inputs.py @@ -13,13 +13,10 @@ drop_deprecated_inputs, find_unrecognized_household_inputs, find_unrecognized_policy_inputs, - household_year, requested_computations, unrecognized_inputs_message, ) -TODAY = date(2027, 3, 1) - def _metadata() -> HouseholdModelMetadata: variables = [ @@ -60,48 +57,57 @@ def _metadata() -> HouseholdModelMetadata: def _situation() -> dict: + # People are named by record ID, as v2 households name them. return { - "people": {"you": {"age": {"2026": 30}}, "partner": {}}, - "households": {"home": {"members": ["you", "partner"]}}, + "people": {"person-1": {"age": {"2025": 30}}, "person-2": {}}, + "households": {"household-1": {"members": ["person-1", "person-2"]}}, } -def test_household_year_is_the_first_age_period_of_you() -> None: - assert household_year(_situation(), today=TODAY) == "2026" - assert household_year({"people": {"partner": {}}}, today=TODAY) == "2027" - assert household_year({"people": {"you": {"age": 30}}}, today=TODAY) == "2027" - - -def test_add_missing_variables_matches_the_public_api() -> None: +def test_add_missing_variables_adds_them_for_the_request_year() -> None: situation = _situation() - add_missing_variables(situation, _metadata(), today=TODAY) + add_missing_variables(situation, _metadata(), year="2025") assert situation == { "people": { - "you": { - "age": {"2026": 30}, - "employment_income": {"2026": 0}, - "is_blind": {"2026": False}, + "person-1": { + "age": {"2025": 30}, + "employment_income": {"2025": 0}, + "is_blind": {"2025": False}, }, - "partner": { - "age": {"2026": 40}, - "employment_income": {"2026": 0}, - "is_blind": {"2026": False}, + "person-2": { + "age": {"2025": 40}, + "employment_income": {"2025": 0}, + "is_blind": {"2025": False}, }, }, "households": { - "home": { - "members": ["you", "partner"], - "monthly_rent": {"2026": 0}, - "state_name": {"2026": "CA"}, + "household-1": { + "members": ["person-1", "person-2"], + "monthly_rent": {"2025": 0}, + "state_name": {"2025": "CA"}, # Calculated variables are requested, never defaulted. - "household_net_income": {"2026": None}, + "household_net_income": {"2025": None}, } }, } +def test_the_year_never_comes_from_a_person_named_you() -> None: + # The Public API takes the first period of the age of a person named + # "you"; a v2 request names its year instead. + situation = { + "people": {"you": {"age": {"2030": 30}}}, + "households": {"home": {"members": ["you"]}}, + } + + add_missing_variables(situation, _metadata(), year="2025") + + assert situation["people"]["you"]["employment_income"] == {"2025": 0} + assert situation["households"]["home"]["household_net_income"] == {"2025": None} + + def test_deprecated_inputs_are_dropped_with_public_api_warnings() -> None: situation = { "people": {"you": {"age": {"2026": 30}, "medical_out_of_pocket_expenses": 5}}, diff --git a/projects/policyengine-simulation-executor/tests/test_household_validation.py b/projects/policyengine-simulation-executor/tests/test_household_validation.py index d49d53ed0..f0e289d8e 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_validation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_validation.py @@ -52,6 +52,7 @@ def test_the_validation_household_is_one_adult_under_current_law( situation = request.household.to_situation() assert request.country == country + assert request.year == "2026" assert request.bundle == installed_household_bundle(country) assert [policy.parameters for policy in request.policies] == [{}] assert list(situation["people"]) == ["you"] diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index 3c757f0ae..db001127f 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -369,6 +369,7 @@ def _household_payload(country: str) -> dict: return { "calculation_id": "00000000-0000-4000-8000-0000000000c1", "country": country, + "year": "2026", "household": {"people": {"you": {"age": {"2026": 40}}}}, "policies": [{"policy_key": "baseline", "parameters": {}}], "bundle": { From 63f9199c81af556dc401a96b34db2b62213db483 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 28 Oct 2026 16:32:41 +0400 Subject: [PATCH 116/124] Give the test copies of the Public API's tables their real column types The household path will read the Public API's households, policies, parameters, parameter values and catalog versions. The tests' copies of those tables now have each column the reads select with the type and nullability the Public API's migrations give it (household_data and value_json are jsonb), and a check compares the copies with a recorded description of the Public API's columns, so a copy cannot drift from the real schema unnoticed. Co-Authored-By: Claude Opus 5.5 --- .../public_api/household_read_columns.json | 36 ++++++++ .../tests/public_api_support.py | 82 ++++++++++++++++--- .../tests/test_public_api_copies.py | 71 ++++++++++++++++ 3 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/tests/fixtures/public_api/household_read_columns.json create mode 100644 libs/policyengine-simulation-persistence/tests/test_public_api_copies.py diff --git a/libs/policyengine-simulation-persistence/tests/fixtures/public_api/household_read_columns.json b/libs/policyengine-simulation-persistence/tests/fixtures/public_api/household_read_columns.json new file mode 100644 index 000000000..deb750634 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/public_api/household_read_columns.json @@ -0,0 +1,36 @@ +{ + "source": "information_schema.columns of a database migrated by the Public API's v2 Alembic chain at revision aeb5f3e8f662", + "tables": { + "households": { + "id": {"type": "uuid", "nullable": false}, + "country_id": {"type": "varchar", "length": 2, "nullable": false}, + "default_year": {"type": "int4", "nullable": true}, + "household_data": {"type": "jsonb", "nullable": false} + }, + "policies": { + "id": {"type": "uuid", "nullable": false}, + "country_id": {"type": "varchar", "length": 2, "nullable": false} + }, + "parameters": { + "id": {"type": "uuid", "nullable": false}, + "name": {"type": "varchar", "length": 512, "nullable": false}, + "tax_benefit_model_version_id": {"type": "uuid", "nullable": false} + }, + "parameter_values": { + "parameter_id": {"type": "uuid", "nullable": false}, + "value_json": {"type": "jsonb", "nullable": false}, + "start_date": {"type": "timestamptz", "nullable": false}, + "end_date": {"type": "timestamptz", "nullable": true}, + "policy_id": {"type": "uuid", "nullable": true} + }, + "tax_benefit_models": { + "id": {"type": "uuid", "nullable": false}, + "name": {"type": "varchar", "length": 32, "nullable": false} + }, + "tax_benefit_model_versions": { + "id": {"type": "uuid", "nullable": false}, + "model_id": {"type": "uuid", "nullable": false}, + "version": {"type": "varchar", "length": 128, "nullable": false} + } + } +} diff --git a/libs/policyengine-simulation-persistence/tests/public_api_support.py b/libs/policyengine-simulation-persistence/tests/public_api_support.py index 2ace7b50b..6552a1b0c 100644 --- a/libs/policyengine-simulation-persistence/tests/public_api_support.py +++ b/libs/policyengine-simulation-persistence/tests/public_api_support.py @@ -1,11 +1,14 @@ -"""The Public API tables the simulation schema refers to, reduced to their keys. +"""The Public API tables the simulation schema refers to or the household path reads. In production these tables belong to the Public API's own migration chain, in the same database as the simulation schema. The tests create only the columns -and unique constraints the simulation schema's foreign keys use, with the -Public API's constraint names, owned by the superuser, and grant the migrator -``REFERENCES`` on them, as the operator does in production. The runtime role -gets no privilege on them. +and constraints the simulation schema's foreign keys and the household reads +use, with the Public API's constraint names, owned by the superuser, and +grant the migrator ``REFERENCES`` on them, as the operator does in +production. Every column the household reads select has the type and +nullability the Public API's migrations give it +(``test_public_api_copies``): ``households.household_data`` and +``parameter_values.value_json`` are ``jsonb``. """ from __future__ import annotations @@ -21,7 +24,16 @@ from sqlalchemy import Engine PUBLIC_API_TABLE_STATEMENTS = ( - "CREATE TABLE public.tax_benefit_model_versions (id uuid PRIMARY KEY)", + "CREATE TABLE public.tax_benefit_models (" + " id uuid PRIMARY KEY," + " name varchar(32) NOT NULL," + " CONSTRAINT uq_tax_benefit_models_name UNIQUE (name))", + "CREATE TABLE public.tax_benefit_model_versions (" + " id uuid PRIMARY KEY," + " model_id uuid NOT NULL REFERENCES public.tax_benefit_models (id)," + " version varchar(128) NOT NULL," + " CONSTRAINT uq_tax_benefit_model_versions_model_version" + " UNIQUE (model_id, version))", "CREATE TABLE public.datasets (" " id uuid PRIMARY KEY," " name varchar(255) NOT NULL," @@ -46,9 +58,37 @@ "CREATE TABLE public.households (" " id uuid PRIMARY KEY," " country_id varchar(2) NOT NULL," + " default_year integer," + " household_data jsonb NOT NULL," " CONSTRAINT uq_households_id_country UNIQUE (id, country_id))", "CREATE TABLE public.dynamics (id uuid PRIMARY KEY)", + "CREATE TABLE public.parameters (" + " id uuid PRIMARY KEY," + " name varchar(512) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " CONSTRAINT uq_parameters_model_version_name" + " UNIQUE (tax_benefit_model_version_id, name))", + "CREATE TABLE public.parameter_values (" + " id uuid PRIMARY KEY," + " parameter_id uuid NOT NULL REFERENCES public.parameters (id)," + " value_json jsonb NOT NULL," + " start_date timestamptz NOT NULL," + " end_date timestamptz," + " policy_id uuid REFERENCES public.policies (id)," + " dynamic_id uuid REFERENCES public.dynamics (id)," + " CONSTRAINT ck_parameter_values_effective_period" + " CHECK (end_date IS NULL OR end_date >= start_date)," + " CONSTRAINT ck_parameter_values_single_owner" + " CHECK (policy_id IS NULL OR dynamic_id IS NULL)," + " CONSTRAINT uq_parameter_values_policy_parameter_start_date" + " UNIQUE (policy_id, parameter_id, start_date))", ) +# The catalog versions' PolicyEngine.py versions, oldest first. +CATALOG_POLICYENGINE_VERSION = "6.9.0" +NEWER_CATALOG_POLICYENGINE_VERSION = "6.10.0" +# A stored household document; these rows are only referred to, never read. +EMPTY_HOUSEHOLD_DOCUMENT = '{"people": []}' REFERENCED_TABLES = ( "tax_benefit_model_versions", "datasets", @@ -92,10 +132,14 @@ def create_public_api_tables(superuser_url: str, *, grant_references: bool) -> N class PublicApiRows: """Public API rows a simulation or run can refer to. - Two catalog versions each have California (``state/ca``) with its own - default dataset, as each publication creates new rows. + Two US catalog versions (PolicyEngine.py ``CATALOG_POLICYENGINE_VERSION`` + and ``NEWER_CATALOG_POLICYENGINE_VERSION``) each have California + (``state/ca``) with its own default dataset, as each publication creates + new rows. """ + us_model_id: UUID + uk_model_id: UUID us_policy_id: UUID uk_policy_id: UUID us_household_id: UUID @@ -110,11 +154,20 @@ class PublicApiRows: def _catalog_statements(rows: PublicApiRows) -> tuple[tuple[str, dict], ...]: return ( ( - "INSERT INTO public.tax_benefit_model_versions (id) VALUES " - "(:version), (:newer_version)", + "INSERT INTO public.tax_benefit_models (id, name) VALUES " + "(:us_model, 'policyengine-us'), (:uk_model, 'policyengine-uk')", + {"us_model": rows.us_model_id, "uk_model": rows.uk_model_id}, + ), + ( + "INSERT INTO public.tax_benefit_model_versions (id, model_id, version) " + "VALUES (:version, :us_model, :policyengine_version), " + "(:newer_version, :us_model, :newer_policyengine_version)", { "version": rows.catalog_version_id, "newer_version": rows.newer_catalog_version_id, + "us_model": rows.us_model_id, + "policyengine_version": CATALOG_POLICYENGINE_VERSION, + "newer_policyengine_version": NEWER_CATALOG_POLICYENGINE_VERSION, }, ), ( @@ -146,6 +199,8 @@ def _catalog_statements(rows: PublicApiRows) -> tuple[tuple[str, dict], ...]: def insert_public_api_rows(superuser_url: str) -> PublicApiRows: """Insert rows into the referenced Public API tables, as their owner.""" rows = PublicApiRows( + us_model_id=uuid4(), + uk_model_id=uuid4(), us_policy_id=uuid4(), uk_policy_id=uuid4(), us_household_id=uuid4(), @@ -164,11 +219,14 @@ def insert_public_api_rows(superuser_url: str) -> PublicApiRows: {"us_policy": rows.us_policy_id, "uk_policy": rows.uk_policy_id}, ), ( - "INSERT INTO public.households (id, country_id) VALUES " - "(:us_household, 'us'), (:uk_household, 'uk')", + "INSERT INTO public.households " + "(id, country_id, default_year, household_data) VALUES " + "(:us_household, 'us', 2026, :document), " + "(:uk_household, 'uk', 2026, :document)", { "us_household": rows.us_household_id, "uk_household": rows.uk_household_id, + "document": EMPTY_HOUSEHOLD_DOCUMENT, }, ), ( diff --git a/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py b/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py new file mode 100644 index 000000000..67b79d13a --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py @@ -0,0 +1,71 @@ +"""The tests' copies of the Public API tables match the Public API's columns. + +The household reads (``household_reads``) select columns of the Public API's +tables. The tests create copies of those tables (``public_api_support``); a +copy whose column has another type or nullability than the real one would +let a read pass here and fail in production, as a text comparison with a +PostgreSQL enum column once did. ``fixtures/public_api/household_read_columns.json`` +records each column read as the Public API's migrations create it. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from public_api_support import superuser_engine +from sqlalchemy import text + +pytestmark = pytest.mark.postgres + +RECORDED_COLUMNS = ( + Path(__file__).parent / "fixtures" / "public_api" / "household_read_columns.json" +) + + +def _recorded() -> dict[str, dict[str, dict[str, object]]]: + return json.loads(RECORDED_COLUMNS.read_text(encoding="utf-8"))["tables"] + + +def _copied(superuser_url: str, tables: list[str]) -> dict[str, dict[str, dict]]: + engine = superuser_engine(superuser_url) + try: + with engine.connect() as connection: + rows = connection.execute( + text( + "SELECT table_name, column_name, udt_name, is_nullable, " + "character_maximum_length FROM information_schema.columns " + "WHERE table_schema = 'public' AND table_name = ANY(:tables)" + ), + {"tables": tables}, + ).all() + finally: + engine.dispose() + copied: dict[str, dict[str, dict]] = {} + for table, column, column_type, nullable, length in rows: + description: dict[str, object] = { + "type": column_type, + "nullable": nullable == "YES", + } + if length is not None: + description["length"] = length + copied.setdefault(table, {})[column] = description + return copied + + +def test_each_column_the_household_reads_select_matches_the_public_api( + disposable_database, +) -> None: + recorded = _recorded() + + copied = _copied(disposable_database.superuser_url, sorted(recorded)) + + mismatches = [ + f"{table}.{column}: copy {copied.get(table, {}).get(column)!r}, " + f"Public API {description!r}" + for table, columns in recorded.items() + for column, description in columns.items() + if copied.get(table, {}).get(column) != description + ] + assert mismatches == [] From c3a841e48863c427d99704dbe84cdd22dd08f161 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 28 Oct 2026 16:36:08 +0400 Subject: [PATCH 117/124] Read the Public API's household, policy and catalog rows a household run needs HouseholdInputReader reads, as the runtime role: the country's catalog version for a PolicyEngine.py version; a household of the country, with its document (people, entities and saved SPM choice) and default_year; and the parameter values a policy of the country sets, by parameter name, each period as the Public API's YYYY-MM-DD.YYYY-MM-DD read in UTC, with an open end as 2100-12-31. A household, policy or catalog version the country does not have is unknown, and a parameter the run's catalog version lacks is refused by name. The runtime role needs SELECT on the six tables read, which the tests grant as the operator will. Co-Authored-By: Claude Opus 5.5 --- .../README.md | 17 +- .../__init__.py | 18 ++ .../household_reads.py | 266 +++++++++++++++++ .../tests/public_api_support.py | 16 +- .../tests/test_household_reads.py | 270 ++++++++++++++++++ 5 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py create mode 100644 libs/policyengine-simulation-persistence/tests/test_household_reads.py diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index a479253b6..6b37b3b26 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -27,8 +27,19 @@ Foreign keys into the Public API's tables in the same database (`policies` and `households`, each together with its country, `dynamics`, `tax_benefit_model_versions`, and `regions` and `datasets` each together with its catalog version) are `ON DELETE RESTRICT`. Creating them needs -`REFERENCES` on those tables for the migrator role; the runtime role needs no -privilege on them. +`REFERENCES` on those tables for the migrator role. + +The household path reads the Public API's rows a household run is +calculated from (`HouseholdInputReader`): the catalog version by country and +PolicyEngine.py version; the household's document, saved SPM choice and +`default_year`; and the policy's parameter values by parameter name, each +period as the Public API's `YYYY-MM-DD.YYYY-MM-DD` read in UTC (an open end +is `2100-12-31`), refused when the run's catalog version lacks a parameter. +The runtime role needs `SELECT` on `HOUSEHOLD_READ_TABLES` +(`tax_benefit_models`, `tax_benefit_model_versions`, `households`, +`policies`, `parameters` and `parameter_values`). The tests' copies of those +tables are checked against the Public API's columns, recorded in +`tests/fixtures/public_api/household_read_columns.json`. ### Household calculations @@ -170,7 +181,7 @@ These rules are registered in `rules/registry.toml` and checked by | Role | Used by | Privileges | | --- | --- | --- | | `simulation_migrator` | The deploy workflow's migration job, through `SIMULATION_MIGRATION_DATABASE_URL` | Creates the `simulation` schema if it is missing, and owns every object in it; `REFERENCES` on the Public API tables its foreign keys point to | -| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create, except `UPDATE` on the definition tables (`simulations`, `reports`) and the result tables; never `DELETE`, and nothing on the Public API tables | +| `simulation_runtime` | The Simulation Entrypoint, through `SIMULATION_DATABASE_URL` | `USAGE` on the schema, `SELECT` on the version table, and `SELECT`, `INSERT` and `UPDATE` on the tables the migrations create, except `UPDATE` on the definition tables (`simulations`, `reports`) and the result tables; never `DELETE`; on the Public API's tables, only `SELECT` on `HOUSEHOLD_READ_TABLES` | The initial revision grants the runtime role its privileges, including default privileges on tables the migrator creates later; revision 0002 revokes diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py index 5b42c5d31..bfb988592 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -24,6 +24,16 @@ HouseholdCalculationStore, HouseholdReport, ) +from policyengine_simulation_persistence.household_reads import ( + HOUSEHOLD_READ_TABLES, + HouseholdCatalogVersion, + HouseholdInputReader, + PolicyParametersMissingError, + StoredHousehold, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, +) from policyengine_simulation_persistence.household_results import ( HouseholdOutput, read_household_output, @@ -79,6 +89,7 @@ __all__ = [ "ALEMBIC_VERSION_TABLE", "HEAD_REVISION", + "HOUSEHOLD_READ_TABLES", "OPTIONAL_SECTIONS", "OUTPUT_FILE_RETENTION", "RESULT_FIELD_STORAGE", @@ -92,6 +103,8 @@ "GeographyLabel", "HouseholdCalculation", "HouseholdCalculationStore", + "HouseholdCatalogVersion", + "HouseholdInputReader", "HouseholdOutput", "HouseholdReport", "NewReport", @@ -100,6 +113,7 @@ "NewSimulationRun", "OutputFile", "OutputFileRecord", + "PolicyParametersMissingError", "ReportKind", "ReportRecord", "ReportRunRecord", @@ -115,6 +129,10 @@ "SimulationRunRecord", "SimulationStore", "StartedSimulationRun", + "StoredHousehold", + "UnknownCatalogVersionError", + "UnknownHouseholdError", + "UnknownPolicyError", "available_output_files", "check_storable", "create_simulation_engine", diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py new file mode 100644 index 000000000..d7e3895ef --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py @@ -0,0 +1,266 @@ +"""Read the Public API's rows a v2 household run is calculated from. + +A household run calculates one stored household under one policy, or current +law, on one catalog version. The Simulation Entrypoint reads, from the Public +API's tables in the same database: + +- the catalog version, by country and PolicyEngine.py version; +- the household, by country and ID: its document (people and entity records + in v2 form, with its saved SPM choice) and its ``default_year``; +- the parameter values the policy sets, by parameter name, checked against + the run's catalog version (``policy_parameters``). + +The runtime role needs ``SELECT`` on ``HOUSEHOLD_READ_TABLES``, which the +owner of the Public API's tables grants. These tables are described here with +only the columns read, in a metadata object of their own; nothing here writes +them. The tests' copies of them are checked against the Public API's columns. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC +from typing import TYPE_CHECKING, Any + +from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + Uuid, + select, +) +from sqlalchemy.dialects.postgresql import JSONB + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + from sqlalchemy import Engine + +household_read_metadata = MetaData() + +tax_benefit_models = Table( + "tax_benefit_models", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("name", String(32)), +) +tax_benefit_model_versions = Table( + "tax_benefit_model_versions", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("model_id", Uuid(as_uuid=True)), + Column("version", String(128)), +) +households = Table( + "households", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("country_id", String(2)), + Column("default_year", Integer), + Column("household_data", JSONB), +) +policies = Table( + "policies", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("country_id", String(2)), +) +parameters = Table( + "parameters", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("name", String(512)), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True)), +) +parameter_values = Table( + "parameter_values", + household_read_metadata, + Column("parameter_id", Uuid(as_uuid=True)), + Column("value_json", JSONB), + Column("start_date", DateTime(timezone=True)), + Column("end_date", DateTime(timezone=True)), + Column("policy_id", Uuid(as_uuid=True)), +) +# The Public API tables the runtime role reads; the operator grants SELECT. +HOUSEHOLD_READ_TABLES = tuple(household_read_metadata.tables) +# The household functions take the Public API's policy JSON, whose periods end +# on this date when a value has no end. +OPEN_END_DATE = "2100-12-31" +# A refused policy's error names at most this many parameters. +MAX_LISTED_PARAMETERS = 10 + + +class UnknownHouseholdError(LookupError): + """The Public API has no such household in the country.""" + + +class UnknownPolicyError(LookupError): + """The Public API has no such policy in the country.""" + + +class UnknownCatalogVersionError(LookupError): + """The country has no catalog version for the PolicyEngine.py version.""" + + +class PolicyParametersMissingError(ValueError): + """The policy changes parameters the run's catalog version does not have.""" + + def __init__(self, names: tuple[str, ...]) -> None: + """Name the missing parameters (at most ``MAX_LISTED_PARAMETERS``).""" + self.names = names + shown = ", ".join(names[:MAX_LISTED_PARAMETERS]) + more = len(names) - MAX_LISTED_PARAMETERS + suffix = f" and {more} more" if more > 0 else "" + super().__init__( + "the policy changes parameters this catalog version does not have: " + f"{shown}{suffix}" + ) + + +@dataclass(frozen=True) +class HouseholdCatalogVersion: + """One published PolicyEngine.py version of one country's catalog.""" + + id: UUID + country: str + policyengine_version: str + + +@dataclass(frozen=True) +class StoredHousehold: + """A household as the Public API stores it. + + ``document`` lists the people and each group entity as records with an + ``id`` and ``values``, each person naming its groups, and may hold the + saved SPM choice (``spm``). ``default_year`` is the year its values name, + or None when it has none. + """ + + id: UUID + country: str + default_year: int | None + document: dict[str, Any] + + +def _period_key(start: datetime, end: datetime | None) -> str: + """Return the Public API's policy period: ``YYYY-MM-DD.YYYY-MM-DD``. + + The Public API stores each date as midnight UTC; the dates are read in + UTC whatever the database session's time zone. A value without an end + runs to ``OPEN_END_DATE``. + """ + start_date = f"{start.astimezone(UTC):%Y-%m-%d}" + end_date = OPEN_END_DATE if end is None else f"{end.astimezone(UTC):%Y-%m-%d}" + return f"{start_date}.{end_date}" + + +class HouseholdInputReader: + """Typed SQLAlchemy Core reads of the Public API's household-run rows.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + def catalog_version( + self, country: str, policyengine_version: str + ) -> HouseholdCatalogVersion: + """Return the country's catalog version for a PolicyEngine.py version. + + Raise UnknownCatalogVersionError when the country has none. + """ + with self.engine.connect() as connection: + version_id = connection.execute( + select(tax_benefit_model_versions.c.id) + .join( + tax_benefit_models, + tax_benefit_models.c.id == tax_benefit_model_versions.c.model_id, + ) + .where( + tax_benefit_models.c.name == f"policyengine-{country}", + tax_benefit_model_versions.c.version == policyengine_version, + ) + ).scalar_one_or_none() + if version_id is None: + msg = ( + f"{country} has no catalog version for PolicyEngine.py " + f"{policyengine_version}" + ) + raise UnknownCatalogVersionError(msg) + return HouseholdCatalogVersion( + id=version_id, country=country, policyengine_version=policyengine_version + ) + + def household(self, country: str, household_id: UUID) -> StoredHousehold: + """Return the country's household; raise UnknownHouseholdError otherwise.""" + with self.engine.connect() as connection: + row = connection.execute( + select(households.c.default_year, households.c.household_data).where( + households.c.id == household_id, + households.c.country_id == country, + ) + ).first() + if row is None: + msg = f"{country} has no household {household_id}" + raise UnknownHouseholdError(msg) + return StoredHousehold( + id=household_id, + country=country, + default_year=row.default_year, + document=row.household_data, + ) + + def policy_parameters( + self, country: str, policy_id: UUID, catalog_version_id: UUID + ) -> dict[str, dict[str, Any]]: + """Return the values the country's policy sets, by name and period. + + The values are stored against parameters of the catalog version the + policy was saved on; a run on another version applies them by + parameter name. Raise UnknownPolicyError when the country has no such + policy, and PolicyParametersMissingError when the run's version lacks + a parameter it changes. + """ + with self.engine.connect() as connection: + policy = connection.execute( + select(policies.c.id).where( + policies.c.id == policy_id, policies.c.country_id == country + ) + ).scalar_one_or_none() + if policy is None: + msg = f"{country} has no policy {policy_id}" + raise UnknownPolicyError(msg) + values = connection.execute( + select( + parameters.c.name, + parameter_values.c.value_json, + parameter_values.c.start_date, + parameter_values.c.end_date, + ) + .join(parameters, parameters.c.id == parameter_values.c.parameter_id) + .where(parameter_values.c.policy_id == policy_id) + .order_by(parameters.c.name, parameter_values.c.start_date) + ).all() + names = sorted({row.name for row in values}) + available = set( + connection.execute( + select(parameters.c.name).where( + parameters.c.tax_benefit_model_version_id == catalog_version_id, + parameters.c.name.in_(names), + ) + ).scalars() + ) + missing = tuple(name for name in names if name not in available) + if missing: + raise PolicyParametersMissingError(missing) + changes: dict[str, dict[str, Any]] = {} + for row in values: + changes.setdefault(row.name, {})[ + _period_key(row.start_date, row.end_date) + ] = row.value_json + return changes diff --git a/libs/policyengine-simulation-persistence/tests/public_api_support.py b/libs/policyengine-simulation-persistence/tests/public_api_support.py index 6552a1b0c..62479cfdf 100644 --- a/libs/policyengine-simulation-persistence/tests/public_api_support.py +++ b/libs/policyengine-simulation-persistence/tests/public_api_support.py @@ -18,6 +18,7 @@ from uuid import UUID, uuid4 from migration_support import MIGRATOR_ROLE +from policyengine_simulation_persistence import HOUSEHOLD_READ_TABLES, RUNTIME_ROLE from sqlalchemy import create_engine, text if TYPE_CHECKING: @@ -108,17 +109,26 @@ def superuser_engine(superuser_url: str, **options: object) -> Engine: def create_public_api_tables(superuser_url: str, *, grant_references: bool) -> None: - """Create the referenced Public API tables in a new test database.""" + """Create the referenced Public API tables in a new test database. + + The runtime role may read the tables a household run is calculated from + (``HOUSEHOLD_READ_TABLES``), as the operator grants in production. + """ engine = superuser_engine(superuser_url, isolation_level="AUTOCOMMIT") grant = ( "GRANT REFERENCES ON " + ", ".join(f"public.{table}" for table in REFERENCED_TABLES) + f" TO {MIGRATOR_ROLE}" ) + read_grant = ( + "GRANT SELECT ON " + + ", ".join(f"public.{table}" for table in HOUSEHOLD_READ_TABLES) + + f" TO {RUNTIME_ROLE}" + ) statements = ( - (*PUBLIC_API_TABLE_STATEMENTS, grant) + (*PUBLIC_API_TABLE_STATEMENTS, grant, read_grant) if grant_references - else PUBLIC_API_TABLE_STATEMENTS + else (*PUBLIC_API_TABLE_STATEMENTS, read_grant) ) try: with engine.connect() as connection: diff --git a/libs/policyengine-simulation-persistence/tests/test_household_reads.py b/libs/policyengine-simulation-persistence/tests/test_household_reads.py new file mode 100644 index 000000000..ba2af05dc --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_reads.py @@ -0,0 +1,270 @@ +"""Reading the Public API's household, policy and catalog rows a household run needs.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest +from policyengine_simulation_persistence import ( + HouseholdInputReader, + PolicyParametersMissingError, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, + create_simulation_engine, +) +from public_api_support import superuser_engine +from sqlalchemy import create_engine, text + +pytestmark = pytest.mark.postgres + +CTC_AMOUNT = "gov.irs.credits.ctc.amount.base[0].amount" +STANDARD_DEDUCTION = "gov.irs.deductions.standard.amount.SINGLE" +RETIRED_PARAMETER = "gov.irs.credits.retired_credit.amount" +US_DOCUMENT = { + "people": [ + { + "id": "person-1", + "values": {"age": {"2025": 40}}, + "memberships": {"household": "household-1"}, + } + ], + "household": [{"id": "household-1", "values": {"state_name": {"2025": "CA"}}}], + "spm": {"scenario": "ce_trend"}, +} + + +@dataclass(frozen=True) +class HouseholdRows: + """The identifiers of the rows each test reads.""" + + older_version_id: UUID + version_id: UUID + uk_version_id: UUID + household_id: UUID + uk_household_id: UUID + policy_id: UUID + retired_policy_id: UUID + empty_policy_id: UUID + + +def _insert_rows(superuser_url: str) -> HouseholdRows: + rows = HouseholdRows( + older_version_id=uuid4(), + version_id=uuid4(), + uk_version_id=uuid4(), + household_id=uuid4(), + uk_household_id=uuid4(), + policy_id=uuid4(), + retired_policy_id=uuid4(), + empty_policy_id=uuid4(), + ) + us_model, uk_model = uuid4(), uuid4() + older_parameters = {name: uuid4() for name in (CTC_AMOUNT, RETIRED_PARAMETER)} + statements: list[tuple[str, dict[str, object]]] = [ + ( + "INSERT INTO public.tax_benefit_models (id, name) VALUES " + "(:us, 'policyengine-us'), (:uk, 'policyengine-uk')", + {"us": us_model, "uk": uk_model}, + ), + ( + "INSERT INTO public.tax_benefit_model_versions (id, model_id, version) " + "VALUES (:older, :us, '6.5.0'), (:version, :us, '6.7.5'), " + "(:uk_version, :uk, '6.7.5')", + { + "older": rows.older_version_id, + "version": rows.version_id, + "uk_version": rows.uk_version_id, + "us": us_model, + "uk": uk_model, + }, + ), + *( + ( + "INSERT INTO public.parameters (id, name, " + "tax_benefit_model_version_id) VALUES (:id, :name, :version)", + {"id": parameter_id, "name": name, "version": rows.older_version_id}, + ) + for name, parameter_id in older_parameters.items() + ), + *( + ( + "INSERT INTO public.parameters (id, name, " + "tax_benefit_model_version_id) VALUES (gen_random_uuid(), :name, " + ":version)", + {"name": name, "version": rows.version_id}, + ) + for name in (CTC_AMOUNT, STANDARD_DEDUCTION) + ), + ( + "INSERT INTO public.policies (id, country_id) VALUES " + "(:policy, 'us'), (:retired, 'us'), (:empty, 'us')", + { + "policy": rows.policy_id, + "retired": rows.retired_policy_id, + "empty": rows.empty_policy_id, + }, + ), + ( + "INSERT INTO public.parameter_values (id, parameter_id, value_json, " + "start_date, end_date, policy_id) VALUES " + "(gen_random_uuid(), :ctc, '3000', '2026-01-01T00:00:00Z', NULL, " + ":policy), " + "(gen_random_uuid(), :ctc, '2500', '2025-01-01T00:00:00Z', " + "'2025-12-31T00:00:00Z', :policy), " + "(gen_random_uuid(), :retired_parameter, 'true', " + "'2026-01-01T00:00:00Z', NULL, :retired)", + { + "ctc": older_parameters[CTC_AMOUNT], + "retired_parameter": older_parameters[RETIRED_PARAMETER], + "policy": rows.policy_id, + "retired": rows.retired_policy_id, + }, + ), + ( + "INSERT INTO public.households (id, country_id, default_year, " + "household_data) VALUES (:household, 'us', 2025, :document), " + "(:uk_household, 'uk', NULL, :uk_document)", + { + "household": rows.household_id, + "uk_household": rows.uk_household_id, + "document": json.dumps(US_DOCUMENT), + "uk_document": json.dumps({"people": [], "benunit": []}), + }, + ), + ] + engine = superuser_engine(superuser_url) + try: + with engine.begin() as connection: + for statement, parameters in statements: + connection.execute(text(statement), parameters) + finally: + engine.dispose() + return rows + + +@pytest.fixture +def household_rows(migrated_database) -> HouseholdRows: + """Insert the catalog, policy and household rows the tests read.""" + return _insert_rows(migrated_database.superuser_url) + + +@pytest.fixture +def reader(migrated_database) -> HouseholdInputReader: + """Return a reader that connects as the runtime role.""" + return HouseholdInputReader(create_simulation_engine(migrated_database.runtime_url)) + + +def test_a_catalog_version_is_found_by_country_and_policyengine_version( + reader, household_rows +) -> None: + version = reader.catalog_version("us", "6.7.5") + + assert version.id == household_rows.version_id + assert version.country == "us" + assert version.policyengine_version == "6.7.5" + assert reader.catalog_version("uk", "6.7.5").id == household_rows.uk_version_id + + +@pytest.mark.parametrize( + ("country", "policyengine_version"), [("us", "9.9.9"), ("uk", "6.5.0")] +) +def test_an_unpublished_catalog_version_is_unknown( + reader, household_rows, country, policyengine_version +) -> None: + with pytest.raises(UnknownCatalogVersionError, match=policyengine_version): + reader.catalog_version(country, policyengine_version) + + +def test_a_household_is_read_with_its_document_and_default_year( + reader, household_rows +) -> None: + household = reader.household("us", household_rows.household_id) + + assert household.id == household_rows.household_id + assert household.country == "us" + assert household.default_year == 2025 + assert household.document == US_DOCUMENT + assert reader.household("uk", household_rows.uk_household_id).default_year is None + + +@pytest.mark.parametrize("which", ["another-country", "missing"]) +def test_a_household_of_another_country_or_none_is_unknown( + reader, household_rows, which +) -> None: + household_id = ( + household_rows.uk_household_id if which == "another-country" else uuid4() + ) + + with pytest.raises(UnknownHouseholdError, match=str(household_id)): + reader.household("us", household_id) + + +def test_a_policys_values_are_read_by_name_on_the_runs_version( + reader, household_rows +) -> None: + # The policy was saved on 6.5.0's parameters; 6.7.5 has the same name. + parameters = reader.policy_parameters( + "us", household_rows.policy_id, household_rows.version_id + ) + + assert parameters == { + CTC_AMOUNT: { + "2025-01-01.2025-12-31": 2500, + "2026-01-01.2100-12-31": 3000, + } + } + + +def test_a_policys_periods_are_read_in_utc_whatever_the_session_time_zone( + migrated_database, household_rows +) -> None: + engine = create_engine( + migrated_database.runtime_url.replace( + "postgresql://", "postgresql+psycopg://", 1 + ), + connect_args={"options": "-c timezone=Pacific/Honolulu"}, + ) + try: + parameters = HouseholdInputReader(engine).policy_parameters( + "us", household_rows.policy_id, household_rows.version_id + ) + finally: + engine.dispose() + + assert set(parameters[CTC_AMOUNT]) == { + "2025-01-01.2025-12-31", + "2026-01-01.2100-12-31", + } + + +def test_a_policy_changing_no_parameter_changes_nothing(reader, household_rows) -> None: + assert ( + reader.policy_parameters( + "us", household_rows.empty_policy_id, household_rows.version_id + ) + == {} + ) + + +def test_a_parameter_the_runs_version_lacks_is_refused_by_name( + reader, household_rows +) -> None: + with pytest.raises(PolicyParametersMissingError, match=RETIRED_PARAMETER) as error: + reader.policy_parameters( + "us", household_rows.retired_policy_id, household_rows.version_id + ) + + assert error.value.names == (RETIRED_PARAMETER,) + + +@pytest.mark.parametrize("country", ["uk", "us"]) +def test_a_policy_of_another_country_or_none_is_unknown( + reader, household_rows, country +) -> None: + policy_id = household_rows.policy_id if country == "uk" else uuid4() + + with pytest.raises(UnknownPolicyError, match=str(policy_id)): + reader.policy_parameters(country, policy_id, household_rows.version_id) From f784b75230979581b20220804ab14909c38fbbd8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:37:10 +0400 Subject: [PATCH 118/124] Read axes and values without a period as the request's year A household may give a variable one value instead of values by period, and an axis without a period. The country model put both in its own default year, 2024 for the US model and 2025 for the UK model, whatever year the household was calculated for, so an axis without a period varied 2024's income while a 2025 household was calculated, and its results did not move. The household function now gives both the request's year before preparing the household, and the calculated household shows them with that period. The Public API leaves them to the country model's default year; the module's list of differences says so. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 11 +++-- .../household_execution.py | 4 +- .../household_calculation.py | 7 ++++ .../household_inputs.py | 34 +++++++++++++++ .../tests/test_household_calculation.py | 36 +++++++++++++++- .../tests/test_household_inputs.py | 41 +++++++++++++++++++ 6 files changed, 126 insertions(+), 7 deletions(-) diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index 9c26cd8ec..59f22c78d 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -468,10 +468,13 @@ optional axes, under one to ten policies, so a baseline and its reform are calculated in one call. It takes a `HouseholdExecutionRequest` and returns a `HouseholdExecutionResult` or a typed `HouseholdExecutionFailure` from `policyengine_simulation_contract.household_execution`, as JSON. The request -names the year it calculates, such as a stored household's default year, and -a calculate-full request adds the household's missing variables for that year; -the Public API instead takes the year from a person named `you`, or the -current year, and v2 households name people by record ID. Otherwise the +names the year it calculates, such as a stored household's default year. A +calculate-full request adds the household's missing variables for that year, +and an axis or a variable value given without a period is for that year too. +The Public API instead takes the year for added variables from a person named +`you`, or the current year (v2 households name people by record ID), and +leaves values and axes without a period to the country model's own default +year. Otherwise the calculation follows the Public API's household calculation, including its input errors, warnings and SPM receipt. A function refuses a request for another country, and returns a `bundle_mismatch` failure for a request that diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py index 4b8688159..1ee1ca87c 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py @@ -111,7 +111,7 @@ dict[PeriodKey, InputScalar], Field(max_length=MAXIMUM_PERIODS_PER_VARIABLE), ] -# A variable's values by period, or one value for the model's default period. +# A variable's values by period, or one value, for the request's year. VariableInput = PeriodInputs | InputScalar EntityInputs = Annotated[ dict[VariableName, VariableInput], @@ -265,7 +265,7 @@ class HouseholdAxis(StrictHouseholdModel): """One input varied across points, as policyengine-core reads an axis. The variable ``name`` takes ``count`` evenly spaced values from ``min`` to - ``max`` in ``period`` (the model's default period when omitted), for the + ``max`` in ``period`` (the request's year when omitted), for the entity member at position ``index`` (the first when omitted). """ diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py index ac8fbe749..b4d0c6f8b 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -22,6 +22,11 @@ adds them for the first period of the age of a person named ``you``, or else its host's current year (and fails with an internal error when that age is one value, not by period); v2 households name people by record ID. +- A variable given as one value, not by period, is that value for ``year``, + and an axis without a period varies ``year``; the calculated household + shows both with that period. The Public API leaves them to the country + model's own default year (2024 for the US, 2025 for the UK), and does not + calculate a variable given as one null value. - An explicit null ``axes`` means no axes, because the contract drops it; the Public API treats the key's presence as a household with axes. - Every policy's parameters are checked before any policy is calculated, so @@ -81,6 +86,7 @@ find_unrecognized_policy_inputs, requested_computations, unrecognized_inputs_message, + with_request_year, ) from policyengine_simulation_executor.household_spm import ( HOUSEHOLD_SPM_INPUT_ERROR_CODES, @@ -576,6 +582,7 @@ def _prepared_household( reported with the policy that names them. """ situation = request.household.to_situation() + with_request_year(situation, year=request.year) if request.add_missing_variables: add_missing_variables(situation, model.metadata, year=request.year) deprecated = drop_deprecated_inputs(situation) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py index b2b684959..333a4b04c 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py @@ -131,6 +131,40 @@ def _metadata_default_value(variable: Variable) -> bool | int | float | str | No return None +def with_request_year(situation: dict[str, Any], *, year: str) -> None: + """Give ``year`` to every axis and value the household gives without a period. + + A variable given as one value, not by period, becomes that value for + ``year``, and an axis without a ``period`` varies ``year``. The country + model would otherwise put both in its own default year (2024 for the US + model, 2025 for the UK model), as the Public API leaves it to, whatever + year the household is calculated for. Membership lists are left alone. + The situation is changed in place. + """ + for entity_plural, entity_group in situation.items(): + if entity_plural == _AXES_KEY: + _give_axes_the_year(entity_group, year) + elif isinstance(entity_group, dict): + for variables in entity_group.values(): + if isinstance(variables, dict): + _date_single_values(variables, year) + + +def _date_single_values(variables: dict[str, Any], year: str) -> None: + for variable_name, value in variables.items(): + if value is None or isinstance(value, (bool, int, float, str)): + variables[variable_name] = {year: value} + + +def _give_axes_the_year(axes: object, year: str) -> None: + if not isinstance(axes, list): + return + for group in axes: + for axis in group: + if isinstance(axis, dict) and axis.get("period") is None: + axis["period"] = year + + def add_missing_variables( situation: dict[str, Any], metadata: HouseholdModelMetadata, diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py index 1e632a75a..ff4d61211 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_calculation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -241,7 +241,8 @@ def test_with_axes_a_failed_value_is_one_null_per_point_with_a_warning() -> None assert situation["households"]["home"]["spm_resources"] == { "2026": [None, None, None] } - assert situation["axes"] == household["axes"] + # The axis named no period, so it varied the request's year. + assert situation["axes"] == [[{**household["axes"][0][0], "period": "2026"}]] assert result.warnings == ( "Unable to calculate spm_resources for home in 2026; returned 3 null " "axis values: 'missing'", @@ -815,6 +816,39 @@ def test_us_calculate_full_calculates_the_request_year_not_this_year() -> None: assert isinstance(situation["people"]["person-1"]["is_blind"]["2025"], bool) +def test_us_an_axis_without_a_period_varies_the_request_year() -> None: + # The US model's own default year is 2024; an axis that named no period + # used to vary 2024's income while the household was calculated for 2025. + household = { + **US_RECORD_HOUSEHOLD_2025, + "people": { + "person-1": {"age": {"2025": 35}, "employment_income": {"2025": 0}}, + "person-2": {"age": {"2025": 5}}, + }, + "households": { + "household-1": { + **US_RECORD_HOUSEHOLD_2025["households"]["household-1"], + "household_net_income": {"2025": None}, + } + }, + "axes": [[{"name": "employment_income", "count": 3, "min": 0, "max": 60_000}]], + } + + outcome = calculate_household( + _request("us", household, year="2025"), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + incomes = situation["households"]["household-1"]["household_net_income"]["2025"] + assert incomes[0] < incomes[1] < incomes[2] + assert situation["people"]["person-1"]["employment_income"] == { + "2025": [0.0, 30_000.0, 60_000.0] + } + + UK_SINGLE = { "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, "benunits": {"your benefit unit": {"members": ["you"]}}, diff --git a/projects/policyengine-simulation-executor/tests/test_household_inputs.py b/projects/policyengine-simulation-executor/tests/test_household_inputs.py index f9c715858..2450398cd 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_inputs.py +++ b/projects/policyengine-simulation-executor/tests/test_household_inputs.py @@ -15,6 +15,7 @@ find_unrecognized_policy_inputs, requested_computations, unrecognized_inputs_message, + with_request_year, ) @@ -108,6 +109,46 @@ def test_the_year_never_comes_from_a_person_named_you() -> None: assert situation["households"]["home"]["household_net_income"] == {"2025": None} +def test_axes_and_values_without_a_period_are_for_the_request_year() -> None: + # The country model would put them in its own default year (2024 for the + # US, 2025 for the UK), not the year the request calculates. + situation = { + "people": { + "person-1": {"age": 30, "employment_income": {"2024": 1}}, + "person-2": {"is_blind": None}, + }, + "households": {"household-1": {"members": ["person-1", "person-2"]}}, + "axes": [ + [ + {"name": "employment_income", "count": 2, "min": 0, "max": 1}, + {"name": "age", "count": 2, "min": 0, "max": 1, "period": "2024"}, + ] + ], + } + + with_request_year(situation, year="2025") + + assert situation == { + "people": { + "person-1": {"age": {"2025": 30}, "employment_income": {"2024": 1}}, + "person-2": {"is_blind": {"2025": None}}, + }, + "households": {"household-1": {"members": ["person-1", "person-2"]}}, + "axes": [ + [ + { + "name": "employment_income", + "count": 2, + "min": 0, + "max": 1, + "period": "2025", + }, + {"name": "age", "count": 2, "min": 0, "max": 1, "period": "2024"}, + ] + ], + } + + def test_deprecated_inputs_are_dropped_with_public_api_warnings() -> None: situation = { "people": {"you": {"age": {"2026": 30}, "medical_out_of_pocket_expenses": 5}}, From c5373322342710fd93fe4bebaf8c3f3e4fb99d25 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 28 Oct 2026 16:37:42 +0400 Subject: [PATCH 119/124] Convert a stored v2 household to the household functions' situation in sim The conversion from a stored household (people and entity records in v2 form) to the situation the household functions take, and the reading of its saved SPM choice, move into the Simulation Entrypoint with their tests, since sim will read the household row itself instead of receiving it. Co-Authored-By: Claude Opus 5.5 --- .../household_conversion.py | 63 ++++++++++ .../tests/test_household_conversion.py | 116 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py create mode 100644 projects/policyengine-simulation-entry/tests/test_household_conversion.py diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py new file mode 100644 index 000000000..f314ee0a0 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py @@ -0,0 +1,63 @@ +"""Stored v2 households, in the situation shape the household functions take. + +A stored household (the Public API's ``households.household_data``) lists +its people and each group entity as records, each with an ``id`` and its +``values``; each person names, for every group collection, the ``id`` of the +group it belongs to. The household functions take the situation shape: +``people`` maps each person to their values, and each group collection, by +its plural, maps each group to its ``members`` and values. Records keep +their ``id`` as their name, so a calculated value maps back to its record. +The saved SPM choice is the document's ``spm``. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +# Each country's stored group collections and their plural in the situation, +# as the Public API's households service accepts them. +GROUP_COLLECTIONS: dict[str, dict[str, str]] = { + "us": { + "household": "households", + "family": "families", + "tax_unit": "tax_units", + "spm_unit": "spm_units", + "marital_unit": "marital_units", + }, + "uk": {"household": "households", "benunit": "benunits"}, +} + + +def household_situation(country: str, document: Mapping[str, Any]) -> dict[str, Any]: + """Return a stored household's people and groups in the situation shape. + + A group no person belongs to keeps an empty member list, which the + household functions' contract refuses. + """ + people = document["people"] + situation: dict[str, Any] = { + "people": {person["id"]: dict(person["values"]) for person in people} + } + for collection, plural in GROUP_COLLECTIONS[country].items(): + situation[plural] = { + group["id"]: { + "members": [ + person["id"] + for person in people + if person["memberships"].get(collection) == group["id"] + ], + **group["values"], + } + for group in document.get(collection, []) + } + return situation + + +def saved_spm_choice(document: Mapping[str, Any]) -> object: + """Return the household's saved SPM selection, or None when it has none.""" + return document.get("spm") diff --git a/projects/policyengine-simulation-entry/tests/test_household_conversion.py b/projects/policyengine-simulation-entry/tests/test_household_conversion.py new file mode 100644 index 000000000..7e04c7343 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_conversion.py @@ -0,0 +1,116 @@ +"""Stored v2 households, in the situation shape the household functions take. + +These cases come from the Public API, which converted households before the +Simulation Entrypoint read them itself. +""" + +from __future__ import annotations + +from policyengine_simulation_entry.household_conversion import ( + household_situation, + saved_spm_choice, +) + +US_HOUSEHOLD = { + "people": [ + { + "id": "person-1", + "source_name": "you", + "values": {"age": {"2026": 40}, "employment_income": {"2026": 30000}}, + "memberships": { + "household": "household-1", + "family": "family-1", + "tax_unit": "tax_unit-1", + "spm_unit": "spm_unit-1", + "marital_unit": "marital_unit-1", + }, + }, + { + "id": "person-2", + "values": {"age": {"2026": 8}}, + "memberships": { + "household": "household-1", + "family": "family-1", + "tax_unit": "tax_unit-1", + "spm_unit": "spm_unit-1", + "marital_unit": "marital_unit-2", + }, + }, + ], + "household": [{"id": "household-1", "values": {"state_name": {"2026": "CA"}}}], + "family": [{"id": "family-1", "values": {}}], + "tax_unit": [{"id": "tax_unit-1", "values": {"eitc": {"2026": None}}}], + "spm_unit": [{"id": "spm_unit-1", "values": {}}], + "marital_unit": [ + {"id": "marital_unit-1", "values": {}}, + {"id": "marital_unit-2", "values": {}}, + ], + "spm": {"scenario": "ce_trend", "geography_kind": "national"}, +} +UK_HOUSEHOLD = { + "people": [ + { + "id": "person-1", + "values": {"age": {"2026": 30}}, + "memberships": {"household": "household-1", "benunit": "benunit-1"}, + } + ], + "household": [{"id": "household-1", "values": {"region": {"2026": "WALES"}}}], + "benunit": [{"id": "benunit-1", "values": {"universal_credit": {"2026": None}}}], +} + + +def test_a_us_household_becomes_a_situation_keyed_by_record_id() -> None: + assert household_situation("us", US_HOUSEHOLD) == { + "people": { + "person-1": {"age": {"2026": 40}, "employment_income": {"2026": 30000}}, + "person-2": {"age": {"2026": 8}}, + }, + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "state_name": {"2026": "CA"}, + } + }, + "families": {"family-1": {"members": ["person-1", "person-2"]}}, + "tax_units": { + "tax_unit-1": {"members": ["person-1", "person-2"], "eitc": {"2026": None}} + }, + "spm_units": {"spm_unit-1": {"members": ["person-1", "person-2"]}}, + "marital_units": { + "marital_unit-1": {"members": ["person-1"]}, + "marital_unit-2": {"members": ["person-2"]}, + }, + } + + +def test_a_uk_household_has_households_and_benefit_units() -> None: + assert household_situation("uk", UK_HOUSEHOLD) == { + "people": {"person-1": {"age": {"2026": 30}}}, + "households": { + "household-1": {"members": ["person-1"], "region": {"2026": "WALES"}} + }, + "benunits": { + "benunit-1": { + "members": ["person-1"], + "universal_credit": {"2026": None}, + } + }, + } + + +def test_a_group_without_members_keeps_an_empty_member_list() -> None: + household = {**UK_HOUSEHOLD, "benunit": [*UK_HOUSEHOLD["benunit"]]} + household["benunit"].append({"id": "benunit-2", "values": {}}) + + situation = household_situation("uk", household) + + assert situation["benunits"]["benunit-2"] == {"members": []} + + +def test_the_saved_spm_choice_is_the_documents_selection_or_none() -> None: + assert saved_spm_choice(US_HOUSEHOLD) == { + "scenario": "ce_trend", + "geography_kind": "national", + } + assert saved_spm_choice(UK_HOUSEHOLD) is None From de06b1a449119e633b7f2f7d934f613347214cfe Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 28 Oct 2026 16:47:26 +0400 Subject: [PATCH 120/124] Take household and policy IDs, and read their rows, on the household routes The household routes now take the IDs of the Public API's stored household and policies, the options and a PolicyEngine.py version, instead of the household's situation, SPM choice and parameter values. Before writing anything, sim reads the country's catalog version for that version, the household (its document, saved SPM choice and default_year) and each policy's parameter values, and converts the household for the household function. Each run is for the household's default_year. A household, policy or catalog version the Public API does not have for the country, a parameter the catalog version lacks, and a household without a default year or that the household functions cannot take get 400 with the reason, declared in the OpenAPI document; an unreadable row gets 503. Running a stored report again takes only the PolicyEngine.py version and reads the report's household and policies again. The staging test submits its stored household's ID. Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 6 +- .../DEPLOYMENT.md | 15 +- .../policyengine-simulation-entry/README.md | 22 +- .../test_staging_household_calculation.py | 86 ++---- .../household_api_models.py | 168 ++++------- .../household_calculations.py | 285 +++++++++++++----- .../household_conversion.py | 4 +- .../household_routes.py | 64 ++-- .../simulation_database.py | 2 + .../tests/household_fakes.py | 146 ++++++++- .../tests/household_harness.py | 36 +-- .../tests/test_household_calculations.py | 120 +++++++- .../tests/test_household_report_routes.py | 48 +-- .../tests/test_household_reports.py | 100 +++--- .../tests/test_household_routes.py | 87 ++++-- .../tests/test_openapi.py | 4 +- .../test_staging_household_deployment.py | 22 +- 17 files changed, 792 insertions(+), 423 deletions(-) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 91c2d5bf2..fa423970b 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -2,10 +2,10 @@ changes: added: - Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced - - Added household calculations on v2 to the Simulation Entrypoint; POST /v2/household-calculations records one stored household under one policy (with its options, such as axes) for a catalog version and year as a simulation run and starts the v2 household function's Modal call, and GET /v2/household-calculations/{calculation_id} returns its status and, once it ended, the calculated household with its warnings, the SPM selection it applied (spm_config), its SPM receipt (spm_provenance) and the bundle, or its error; the same calculation reuses its run while unfinished or for 28 days after it succeeded, unless forced; the Public API sends the household with its saved SPM choice and the policy's parameter values - - Added household reports on v2; POST /v2/household-reports creates a report of a household under a baseline and an optional reform and starts its first run, POST /v2/household-reports/{report_id}/runs starts another, and GET /v2/household-reports/{report_id} returns the newest run with both calculations, whose pending runs one Modal call computes together + - Added household calculations on v2 to the Simulation Entrypoint; POST /v2/household-calculations takes the IDs of a stored household and policy (none for current law), its options, such as axes, and a PolicyEngine.py version, reads the household, the policy's parameter values and the catalog version from the Public API's tables, records the calculation for the household's default_year as a simulation run and starts the v2 household function's Modal call, and GET /v2/household-calculations/{calculation_id} returns its status and, once it ended, the calculated household with its warnings, the SPM selection it applied (spm_config), its SPM receipt (spm_provenance) and the bundle, or its error; the same calculation reuses its run while unfinished or for 28 days after it succeeded, unless forced; the runtime role needs SELECT on the Public API's tax_benefit_models, tax_benefit_model_versions, households, policies, parameters and parameter_values + - Added household reports on v2; POST /v2/household-reports creates a report of a stored household under a baseline and an optional reform, each by policy ID, and starts its first run, POST /v2/household-reports/{report_id}/runs starts another on a PolicyEngine.py version, and GET /v2/household-reports/{report_id} returns the newest run with both calculations, whose pending runs one Modal call computes together - Made the v2 household functions report their call's end to the Simulation Entrypoint as their last step, given the runs a call computes, and added a household result recorder, so each household call's outcome is recorded when it reports, or by the safety check; a household function's typed failure fails the runs with household_calculation_failed and keeps its error as the runs' error detail (revision 0005 adds simulation options, the household output's warnings, SPM documents and bundle, and error details) - - Added a staging household test that runs after each staging deployment promotes its revision: it submits a US household calculation to the stable URL and reads the result back within ten minutes; it reads the staging Public API's URL from the new beta environment variable PUBLIC_API_URL + - Added a staging household test that runs after each staging deployment promotes its revision: it stores a US household through the staging Public API, submits its ID to the stable URL and reads the result back within ten minutes; it reads the staging Public API's URL from the new beta environment variable PUBLIC_API_URL - Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput - Declared local_area_breakdown version 1 in the routing bundle manifest of apps whose policyengine.py bundle routes both UK area region types to one local-area release dataset (regional_release_kind local_area; an area_coded_national bundle, or one routing the two types to different datasets, does not count as routed), and made the gateway refuse breakdown with 400, before spawning, for any route whose manifest does not declare it, including legacy routes and routes pinned to an older version, since older apps ignore the field and would return a national result - Made a routed local-area dataset fail as it loads, before any simulation, when it lacks any of the four household columns the area outputs read (constituency_code_oa, la_code_oa, region_code_oa and source_household_id), for breakdown, constituency and local-authority runs alike, in the runtime and in the Stage 12 worker; Stage 12 copies the same columns diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index a2250d06e..95fab18c1 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -93,7 +93,12 @@ they tested, so the main URL reaches it once promotion succeeds. Household calculations (`POST /v2/household-calculations`, see the README) start the v2 household functions' calls in the same Modal environment, `SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`, with the same token, and read the -v2 version manifest there; they need no other setting. Their outcomes are +v2 version manifest there. They read the household, its policies and the +catalog version from the Public API's tables through `SIMULATION_DATABASE_URL`, +so before they are used the owner of those tables must grant the runtime role +`simulation_runtime` `SELECT` on `tax_benefit_models`, +`tax_benefit_model_versions`, `households`, `policies`, `parameters` and +`parameter_values`; without it, submissions answer 503. Their outcomes are recorded like any other call's: on the call's report, or by the safety check. The household functions take the runs a call computes as their second argument. A v2 application deployed before they did (for an older @@ -113,11 +118,13 @@ after promotion because the household function reports the call's end to the stable URL. A failure fails the deployment run, so production does not deploy. -The calculation refers to rows the Public API owns in the same database, so -the test first reads the staging Public API's US catalog version for the +The calculation reads rows the Public API owns in the same database, so +the test first finds the staging Public API's US catalog version for the deployed PolicyEngine.py version (or its current one, when the Public API has not published that version yet) and stores a one-person household there -(`POST /v2/households`, which returns the same household every time). The +(`POST /v2/households`, which returns the same household every time), then +submits that household's ID and the catalog version's PolicyEngine.py +version. The `beta` environment variable `PUBLIC_API_URL` names the staging Public API's URL. The test authenticates as the deployed-test client (`SIMULATION_ENTRYPOINT_TEST_AUTH_*`), which needs `simulation:submit` and diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index d3cde5491..e2b935576 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -29,15 +29,22 @@ job after a staging promotion (`DEPLOYMENT.md`, "Staging household test"). ## Household calculations `POST /v2/household-calculations` records a household calculation, one -household under one policy for a catalog version and year, and starts its +stored household under one stored policy or current law, and starts its Modal call; `GET /v2/household-calculations/{calculation_id}` returns its status and, once it succeeded, the calculated household with its warnings, the SPM selection it applied (`spm_config`), its SPM receipt (`spm_provenance`) and its bundle, or, once it failed, its error. The Public -API sends the household in the situation shape with its saved SPM choice -and the policy's parameter values, because this service cannot read the -Public API's tables; the database checks that the household, policy and -catalog version exist. +API sends the IDs of its household and policy and the PolicyEngine.py +version; this service reads, from the Public API's tables in the same +database, the country's catalog version for that PolicyEngine.py version, +the household (its document, saved SPM choice and `default_year`, the year +calculated) and the policy's parameter values, before writing anything. +A household, policy or catalog version the Public API does not have for the +country, a policy changing a parameter the catalog version lacks, and a +household without a default year or that the household functions cannot +take are refused with 400 and the reason. The runtime role needs `SELECT` +on those tables (`libs/policyengine-simulation-persistence`, "Household +calculations"). Each calculation is a simulation run of a household simulation (household, policy and options such as axes), so the same calculation submitted again @@ -52,8 +59,9 @@ or that environment, the routes answer 503. A household report compares the household under a baseline and an optional reform. `POST /v2/household-reports` creates it and starts its first run; -`POST /v2/household-reports/{report_id}/runs` starts another run of it, with -the report's own household and policies; `GET /v2/household-reports/{report_id}` +`POST /v2/household-reports/{report_id}/runs` starts another run of it on a +PolicyEngine.py version, reading the report's own household and policies +again; `GET /v2/household-reports/{report_id}` returns it with its newest run and that run's two calculations. Each report run joins a run of each simulation, reused like any calculation's, and one Modal call computes the runs that still need computing, so a new report's diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py index 3ff1b0a3a..365899354 100644 --- a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py @@ -5,14 +5,15 @@ skips it. It runs against the stable URL because the household function reports the call's end there, so only the promoted revision records it. -The calculation needs rows the Public API owns in the same database: a -catalog version and a stored household. The test reads the staging Public -API's US catalog version for the deployed PolicyEngine.py version (or, when -the Public API has not published that version yet, its current one) and -stores a one-person household there, which returns the same household each -time. It then submits that household, as stored, forced so each deploy -starts a new Modal call, polls until it succeeds or fails, and reads the -result back, all within one ten-minute limit. An answer asking to retry (503 +The calculation needs rows the Public API owns in the same database, which +sim reads itself: a catalog version and a stored household. The test finds +the staging Public API's US catalog version for the deployed PolicyEngine.py +version (or, when the Public API has not published that version yet, its +current one) and stores a one-person household there, which returns the +same household each time. It then submits that household's ID and the +catalog version's PolicyEngine.py version, forced so each deploy starts a +new Modal call, polls until it succeeds or fails, and reads the result +back, all within one ten-minute limit. An answer asking to retry (503 with ``Retry-After``) is retried after its wait, within that limit; a submission retried after it was partly written may leave a pending run behind, which nothing reuses, since each retry is forced too. @@ -75,41 +76,6 @@ "spm_unit": [{"id": "your spm unit", "values": {}}], "marital_unit": [{"id": "your marital unit", "values": {}}], } -# The plural each stored group collection has in the situation shape. -SITUATION_COLLECTIONS = { - "household": "households", - "family": "families", - "tax_unit": "tax_units", - "spm_unit": "spm_units", - "marital_unit": "marital_units", -} - - -def situation_from_stored(stored: dict[str, Any]) -> dict[str, Any]: - """Return a stored household in the situation shape the household functions take. - - Each record's ``id`` names it, and each group lists the people whose - memberships name it, so both shapes hold the same household. - """ - situation: dict[str, Any] = { - "people": {person["id"]: dict(person["values"]) for person in stored["people"]} - } - for collection, plural in SITUATION_COLLECTIONS.items(): - situation[plural] = { - group["id"]: { - "members": [ - person["id"] - for person in stored["people"] - if person["memberships"][collection] == group["id"] - ], - **group["values"], - } - for group in stored[collection] - } - return situation - - -SITUATION = situation_from_stored(STORED_HOUSEHOLD) def _required(name: str) -> str: @@ -154,8 +120,8 @@ def authorization() -> dict[str, str]: return {"Authorization": f"Bearer {token}"} -def catalog_version(public_api_url: str, policyengine_version: str) -> dict[str, str]: - """Return the US catalog version for the deployed version, or the current one.""" +def catalog_policyengine_version(public_api_url: str, policyengine_version: str) -> str: + """Return the deployed version if the Public API publishes it, else its current.""" url = f"{public_api_url}/v2/tax-benefit-models/by-country/us" response = httpx.get( url, params={"policyengine_version": policyengine_version}, timeout=30 @@ -163,11 +129,7 @@ def catalog_version(public_api_url: str, policyengine_version: str) -> dict[str, if response.status_code == httpx.codes.NOT_FOUND: response = httpx.get(url, timeout=30) response.raise_for_status() - result = response.json()["result"] - return { - "catalog_version_id": result["model_version"]["id"], - "policyengine_version": result["policyengine_version"], - } + return response.json()["result"]["policyengine_version"] def stored_household_id(public_api_url: str) -> str: @@ -186,6 +148,16 @@ def stored_household_id(public_api_url: str) -> str: return response.json()["result"]["item"]["id"] +def submission_body(household_id: str, policyengine_version: str) -> dict[str, Any]: + """Return a forced calculation of the stored household, by ID only.""" + return { + "country": "us", + "policyengine_version": policyengine_version, + "household_id": household_id, + "force": True, + } + + def answer_within( send: Callable[[], httpx.Response], deadline: float ) -> httpx.Response: @@ -236,21 +208,14 @@ def test_a_household_calculation_succeeds_and_reads_back( base_url: str, public_api_url: str, authorization: dict[str, str] ) -> None: deadline = time.monotonic() + CALCULATION_DEADLINE_SECONDS - catalog = catalog_version( + policyengine_version = catalog_policyengine_version( public_api_url, _required("SIMULATION_STAGING_TEST_POLICYENGINE_VERSION") ) household_id = stored_household_id(public_api_url) submitted = answer_within( lambda: httpx.post( f"{base_url}/v2/household-calculations", - json={ - "country": "us", - **catalog, - "year": YEAR, - "household_id": household_id, - "household": SITUATION, - "force": True, - }, + json=submission_body(household_id, policyengine_version), headers=authorization, timeout=60, ), @@ -264,12 +229,13 @@ def test_a_household_calculation_succeeds_and_reads_back( ) assert finished["status"] == "succeeded", finished["error"] + assert finished["year"] == YEAR result = finished["result"] household = result["household"]["households"]["your household"] net_income = household["household_net_income"][str(YEAR)] assert isinstance(net_income, int | float) assert net_income > 0 - assert result["bundle"]["policyengine_version"] == catalog["policyengine_version"] + assert result["bundle"]["policyengine_version"] == policyengine_version read_back = httpx.get( f"{base_url}/v2/household-calculations/{calculation_id}", headers=authorization, diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py index 1baa52ac2..4af55675e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -1,10 +1,10 @@ """Request and response models of the household calculation routes. -The Public API sends what the Simulation Entrypoint cannot read itself: the -household in the situation shape the household functions take, with its -saved SPM choice, and each policy's parameter values. The Simulation -Entrypoint records the calculation against the Public API's IDs (household, -policy, catalog version), whose rows the database checks exist. +The Public API sends the IDs of its stored household and policies and the +PolicyEngine.py version to calculate with; the Simulation Entrypoint reads +the household, the policies' parameter values and the catalog version from +the Public API's tables itself (``HouseholdInputReader``), and calculates +the household's ``default_year``. This is v2 code: the v1 ``run_simulation`` code does not use it. """ @@ -15,14 +15,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Self from uuid import UUID -from policyengine_simulation_contract.household_execution import ( - MAXIMUM_POLICY_PARAMETERS, - Axes, - HouseholdDocument, - ParameterPath, - ParameterPeriod, - ParameterValue, -) +from policyengine_simulation_contract.household_execution import Axes from policyengine_simulation_contract.stage12_bundle import CountryId from policyengine_simulation_persistence import ( NewSimulation, @@ -30,15 +23,7 @@ RunTrigger, SimulationKind, ) -from policyengine_simulation_persistence.reports import FIRST_YEAR, LAST_YEAR -from pydantic import ( - BaseModel, - ConfigDict, - Field, - JsonValue, - field_validator, - model_validator, -) +from pydantic import BaseModel, ConfigDict, Field, JsonValue if TYPE_CHECKING: from policyengine_simulation_persistence import ( @@ -46,11 +31,6 @@ HouseholdReport, ) -PolicyParameters = Annotated[ - dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], - Field(max_length=MAXIMUM_POLICY_PARAMETERS), -] - class StrictRouteModel(BaseModel): """Base for the household routes' models: no unknown fields, finite numbers.""" @@ -58,24 +38,10 @@ class StrictRouteModel(BaseModel): model_config = ConfigDict(extra="forbid", allow_inf_nan=False) -class HouseholdPolicyInput(StrictRouteModel): - """A policy: its Public API ID, or none for current law, and its values. - - ``parameters`` maps each changed parameter path to its values by period - range (``"YYYY-MM-DD.YYYY-MM-DD"``), as in the Public API's policy JSON. - Current law changes no parameters. - """ +class HouseholdPolicyChoice(StrictRouteModel): + """A policy stored in the Public API, by ID, or none for current law.""" policy_id: UUID | None = None - parameters: PolicyParameters = Field(default_factory=dict) - - @model_validator(mode="after") - def require_a_policy_for_parameters(self) -> Self: - """Refuse parameter values without the policy they belong to.""" - if self.policy_id is None and self.parameters: - msg = "current law (no policy_id) changes no parameters" - raise ValueError(msg) - return self class HouseholdCalculationOptions(StrictRouteModel): @@ -102,63 +68,24 @@ def stored(self) -> dict[str, Any]: return stored -PolicyEngineVersion = Annotated[str, Field(min_length=1, max_length=255)] -Year = Annotated[int, Field(ge=FIRST_YEAR, le=LAST_YEAR)] +PolicyEngineVersion = Annotated[str, Field(min_length=1, max_length=128)] -class StoredHouseholdInputs(StrictRouteModel): - """A stored household and the catalog version to calculate it on. +class HouseholdCalculationSubmission(StrictRouteModel): + """One stored household under one policy, to calculate. - ``catalog_version_id`` is the Public API's catalog version and - ``policyengine_version`` the PolicyEngine.py version it was published - from, which picks the household function. ``household`` is the stored - household in the situation shape, without axes (they are an option); - ``spm`` is its saved SPM choice, omitted or null when it has none, so - the bundle's certified defaults apply. + ``household_id`` and ``policy_id`` (none for current law) name the + Public API's rows of ``country``; ``policyengine_version`` selects the + country's catalog version and the household function. The calculation + is for the household's ``default_year``. A calculation submitted again + reuses its run while it is unfinished or for 28 days after it succeeded, + unless ``force``; after that, it runs again under a new calculation ID. """ - catalog_version_id: UUID + country: CountryId policyengine_version: PolicyEngineVersion household_id: UUID - household: HouseholdDocument - spm: JsonValue = None - - @field_validator("household") - @classmethod - def refuse_axes_in_the_household( - cls, household: HouseholdDocument - ) -> HouseholdDocument: - """Require axes in ``options``, where they are part of the calculation.""" - if household.has_axes: - msg = "send axes in options, not in the household" - raise ValueError(msg) - return household - - @property - def spm_requested(self) -> bool: - """Whether the household has a saved SPM choice to calculate with. - - A saved household's null selection is no choice, as the household - functions' contract reads it. - """ - return "spm" in self.model_fields_set and self.spm is not None - - -class HouseholdCalculationSubmission(StoredHouseholdInputs): - """One household under one policy, to calculate for a catalog version and year. - - ``year`` is the household's calculation year, part of what identifies - the run; the household's own periods decide what is calculated, so the - caller sends the year those periods name. ``policyengine_version`` must - be the catalog version's: the Simulation Entrypoint cannot read the - catalog to check it. A calculation submitted again reuses its run while - it is unfinished or for 28 days after it succeeded, unless ``force``; - after that, it runs again under a new calculation ID. - """ - - country: CountryId - year: Year - policy: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) + policy_id: UUID | None = None options: HouseholdCalculationOptions = Field( default_factory=HouseholdCalculationOptions ) @@ -168,60 +95,69 @@ class HouseholdCalculationSubmission(StoredHouseholdInputs): def simulation_definition(self) -> NewSimulation: """Return the household simulation this calculation runs.""" return household_simulation_definition( - self, self.country, self.policy, self.options + self.country, self.household_id, self.policy_id, self.options ) -class HouseholdReportSubmission(StoredHouseholdInputs): - """A household report to create: the household under a baseline and a reform. +class HouseholdReportSubmission(StrictRouteModel): + """A household report to create: a stored household under a baseline and a reform. - ``reform`` is omitted for a report of the baseline alone. Both policies - are calculated with the same ``options``, in one Modal call when neither - has a run to reuse. The report's first run starts at once. + ``reform`` is omitted for a report of the baseline alone; each policy is + a stored policy's ID, or none for current law. Both are calculated with + the same ``options``, for the household's ``default_year``, in one Modal + call when neither has a run to reuse. The report's first run starts at + once. """ country: CountryId - year: Year - baseline: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) - reform: HouseholdPolicyInput | None = None + policyengine_version: PolicyEngineVersion + household_id: UUID + baseline: HouseholdPolicyChoice = Field(default_factory=HouseholdPolicyChoice) + reform: HouseholdPolicyChoice | None = None options: HouseholdCalculationOptions = Field( default_factory=HouseholdCalculationOptions ) force: bool = False trigger: RunTrigger = RunTrigger.INITIAL - def simulation_definition(self, policy: HouseholdPolicyInput) -> NewSimulation: + def policy_ids(self) -> list[UUID | None]: + """Return the baseline's policy and, when there is a reform, its policy.""" + return [self.baseline.policy_id] + ( + [] if self.reform is None else [self.reform.policy_id] + ) + + def simulation_definition(self, policy_id: UUID | None) -> NewSimulation: """Return the household simulation of one of the report's policies.""" - return household_simulation_definition(self, self.country, policy, self.options) + return household_simulation_definition( + self.country, self.household_id, policy_id, self.options + ) -class HouseholdReportRunSubmission(StoredHouseholdInputs): +class HouseholdReportRunSubmission(StrictRouteModel): """Another run of a stored household report, on a catalog version. - The household and both policies must be the report's own: the Public API - sends their contents again, because the Simulation Entrypoint cannot - read them. The runs are reused while unfinished or for 28 days after - they succeeded, unless ``force``. + The report's household, policies, options and year are read from the + report; only the PolicyEngine.py version is chosen. The runs are reused + while unfinished or for 28 days after they succeeded, unless ``force``. """ - baseline: HouseholdPolicyInput = Field(default_factory=HouseholdPolicyInput) - reform: HouseholdPolicyInput | None = None + policyengine_version: PolicyEngineVersion force: bool = False trigger: RunTrigger = RunTrigger.MANUAL def household_simulation_definition( - inputs: StoredHouseholdInputs, country: str, - policy: HouseholdPolicyInput, + household_id: UUID, + policy_id: UUID | None, options: HouseholdCalculationOptions, ) -> NewSimulation: - """Return the household simulation of ``inputs`` under ``policy``.""" + """Return the household simulation of a household under a policy.""" return NewSimulation( kind=SimulationKind.HOUSEHOLD, country=country, - household_id=inputs.household_id, - policy_id=policy.policy_id, + household_id=household_id, + policy_id=policy_id, options=options.stored(), ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index a2e6f08a2..9536545d7 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -6,12 +6,17 @@ 1. finds the household function the calculation's PolicyEngine.py version deployed, in the v2 version manifest, before anything is written; -2. finds or creates the simulation, then reuses its run for that catalog +2. reads, from the Public API's tables, the country's catalog version for + that PolicyEngine.py version, the stored household (its document, saved + SPM choice and ``default_year``, the year calculated) and each policy's + parameter values by name, refusing a household, policy or catalog + version the Public API does not have, a parameter the catalog version + lacks, and a household without a default year or that the household + functions cannot take, again before anything is written; +3. finds or creates the simulation, then reuses its run for that catalog version and year (while unfinished, or for 28 days after it succeeded, - ``SUCCEEDED_RUN_REUSE_WINDOW``) or starts the next attempt; the database - refuses a household, policy or catalog version the Public API does not - have; -3. starts the run's Modal call if the run is pending, whoever created it, so + ``SUCCEEDED_RUN_REUSE_WINDOW``) or starts the next attempt; +4. starts the run's Modal call if the run is pending, whoever created it, so a run another submitter left pending runs too. Only the submitter whose claim moves the run to ``dispatching`` starts the call (``RunDispatchService``). The household function reports when the call @@ -26,7 +31,9 @@ of its simulations, joins them in a report run, and starts one Modal call for those still pending, so a new report's two households are calculated together. When another submitter claimed one of them first, it starts that -one's call, and this submitter starts a call for the rest. +one's call, and this submitter starts a call for the rest. Running a stored +report again reads its household and policies from the report's own +simulations. This is v2 code: the v1 ``run_simulation`` code does not use it. """ @@ -35,9 +42,10 @@ import asyncio from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Protocol from policyengine_simulation_contract.household_execution import ( + HouseholdDocument, HouseholdExecutionRequest, ) from policyengine_simulation_persistence import ( @@ -47,11 +55,17 @@ ReportKind, RunStatus, ) +from policyengine_simulation_persistence.reports import FIRST_YEAR, LAST_YEAR +from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from policyengine_simulation_entry.household_api_models import ( HouseholdCalculationOptions, ) +from policyengine_simulation_entry.household_conversion import ( + household_situation, + saved_spm_choice, +) from policyengine_simulation_entry.household_recording import policy_key_for_run from policyengine_simulation_entry.modal_calls import ModalRequest from policyengine_simulation_entry.run_dispatch import DispatchOutcome @@ -60,11 +74,9 @@ from collections.abc import Callable, Collection, Mapping, Sequence from uuid import UUID - from policyengine_simulation_contract.household_execution import ( - HouseholdDocument, - ) from policyengine_simulation_persistence import ( HouseholdCalculation, + HouseholdCatalogVersion, HouseholdReport, NewSimulation, ReportRecord, @@ -73,12 +85,12 @@ SimulationRecord, SimulationRunRecord, StartedSimulationRun, + StoredHousehold, ) from pydantic import JsonValue from policyengine_simulation_entry.household_api_models import ( HouseholdCalculationSubmission, - HouseholdPolicyInput, HouseholdReportRunSubmission, HouseholdReportSubmission, ) @@ -89,17 +101,18 @@ ) -MISMATCHED_REPORT_MESSAGE = ( - "A report's run calculates the report's own household and policies." -) +class UnknownHouseholdReferenceError(LookupError): + """The database refused a household, policy or catalog version reference. + The rows were read before writing, so the Public API deleted one since. + """ -class UnknownHouseholdReferenceError(LookupError): - """The household, policy or catalog version does not exist for the country.""" +class HouseholdNotCalculableError(ValueError): + """The stored household has no default year, or the household functions refuse it. -class ReportPoliciesMismatchError(ValueError): - """A report's run names another household or other policies than the report.""" + The message names the household and why. + """ class HouseholdCalculationRecords(Protocol): @@ -154,6 +167,26 @@ async def start_simulation_runs( ... +class HouseholdRowReader(Protocol): + """Read the Public API's rows a household run needs (``HouseholdInputReader``).""" + + def catalog_version( + self, country: str, policyengine_version: str + ) -> HouseholdCatalogVersion: + """Return the country's catalog version for a PolicyEngine.py version.""" + ... + + def household(self, country: str, household_id: UUID) -> StoredHousehold: + """Return the country's stored household.""" + ... + + def policy_parameters( + self, country: str, policy_id: UUID, catalog_version_id: UUID + ) -> dict[str, dict[str, Any]]: + """Return the values the country's policy sets, by name and period.""" + ... + + class HouseholdWorkerSource(Protocol): """Find a country's household function (``ManifestHouseholdWorkers``).""" @@ -192,30 +225,93 @@ def options(self) -> HouseholdCalculationOptions: @dataclass(frozen=True) -class ReportRunInputs: - """What a stored report's Modal call calculates, besides its policies.""" +class HouseholdRunInputs: + """What a household's runs calculate, read from the Public API's rows. + + ``year`` is the household's ``default_year``; ``parameters`` holds each + policy's values, by policy ID (None for current law, which changes + nothing). + """ country: str + catalog_version_id: UUID + year: int household: HouseholdDocument spm: JsonValue spm_requested: bool options: HouseholdCalculationOptions + parameters: Mapping[UUID | None, Mapping[str, object]] -def _check_report_policies( - simulations: Sequence[SimulationRecord], - policies: Sequence[HouseholdPolicyInput], - household_id: UUID, -) -> None: - """Refuse a run whose household or policies are not the report's own. +def _calculable_household(country: str, stored: StoredHousehold) -> HouseholdDocument: + """Return the stored household as the household functions take it. - ``simulations`` are the report's baseline and, if it has one, reform; - ``policies`` the submitted baseline and reform, in the same order. + Raise HouseholdNotCalculableError when it has no default year in the + runs' range, or the household functions' contract refuses it, such as a + group no person belongs to. + """ + year = stored.default_year + if year is None or not FIRST_YEAR <= year <= LAST_YEAR: + msg = f"the household {stored.id} has no default year to calculate" + raise HouseholdNotCalculableError(msg) + try: + return HouseholdDocument.model_validate( + household_situation(country, stored.document) + ) + except ValidationError as error: + reasons = "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in error.errors() + ) + msg = f"the household {stored.id} cannot be calculated: {reasons}" + raise HouseholdNotCalculableError(msg) from None + except (KeyError, TypeError) as error: + msg = f"the household {stored.id} cannot be calculated: {error!r}" + raise HouseholdNotCalculableError(msg) from None + + +def read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calculates + reads: HouseholdRowReader, + *, + country: str, + household_id: UUID, + policy_ids: Sequence[UUID | None], + policyengine_version: str, + options: HouseholdCalculationOptions, +) -> HouseholdRunInputs: + """Read and check everything the household's runs calculate. + + Raise the reads' errors for a catalog version, household or policy the + Public API does not have or a parameter the version lacks, and + HouseholdNotCalculableError for a household the runs cannot calculate. """ - if [simulation.policy_id for simulation in simulations] != [ - policy.policy_id for policy in policies - ] or any(simulation.household_id != household_id for simulation in simulations): - raise ReportPoliciesMismatchError(MISMATCHED_REPORT_MESSAGE) + version = reads.catalog_version(country, policyengine_version) + stored = reads.household(country, household_id) + household = _calculable_household(country, stored) + spm = saved_spm_choice(stored.document) + parameters: dict[UUID | None, Mapping[str, object]] = {} + for policy_id in dict.fromkeys(policy_ids): + parameters[policy_id] = ( + {} + if policy_id is None + else reads.policy_parameters(country, policy_id, version.id) + ) + # Checked in _calculable_household. + year = stored.default_year + if year is None: # pragma: no cover + msg = "a calculable household has a default year" + raise RuntimeError(msg) + return HouseholdRunInputs( + country=country, + catalog_version_id=version.id, + year=year, + household=household, + spm=spm, + # A saved null selection is no choice, as the contract reads it. + spm_requested=spm is not None, + options=options, + parameters=parameters, + ) def household_modal_request( @@ -267,6 +363,7 @@ class HouseholdCalculationService: store: HouseholdCalculationRecords dispatch: SimulationRunStarter workers: HouseholdWorkerSource + reads: HouseholdRowReader async def submit( self, submission: HouseholdCalculationSubmission @@ -274,12 +371,22 @@ async def submit( """Record the calculation, start its call if needed, and return it. Raise HouseholdFunctionUnavailableError or HouseholdWorkersUnavailableError - before anything is written, and UnknownHouseholdReferenceError when - the database refuses its references. + and the row reads' refusals before anything is written, and + UnknownHouseholdReferenceError when the database refuses a reference. """ worker = await asyncio.to_thread( self.workers.resolve, submission.country, submission.policyengine_version ) + inputs = await asyncio.to_thread( + lambda: read_household_inputs( + self.reads, + country=submission.country, + household_id=submission.household_id, + policy_ids=[submission.policy_id], + policyengine_version=submission.policyengine_version, + options=submission.options, + ) + ) def start() -> StartedSimulationRun: simulation = self.store.find_or_create_simulation( @@ -288,8 +395,8 @@ def start() -> StartedSimulationRun: return self.store.start_run( NewSimulationRun( simulation_id=simulation.id, - tax_benefit_model_version_id=submission.catalog_version_id, - year=submission.year, + tax_benefit_model_version_id=inputs.catalog_version_id, + year=inputs.year, trigger=submission.trigger, ), force=submission.force, @@ -299,8 +406,8 @@ def start() -> StartedSimulationRun: await self._start_pending_runs( worker, calculation_id=run.id, - inputs=submission, - runs=[(run, submission.policy.parameters)], + inputs=inputs, + runs=[(run, inputs.parameters[submission.policy_id])], ) calculation = await self.read(run.id) if calculation is None: # pragma: no cover - runs are never deleted @@ -317,29 +424,38 @@ async def submit_report( ) -> HouseholdReport: """Create a household report and start its first run. - The runs start before the report is created, so a household, policy - or catalog version the database refuses leaves no report behind. - Raise as ``submit`` does. + The rows are read and the runs start before the report is created, so + a submission sim cannot calculate leaves no report behind. Raise as + ``submit`` does. """ worker = await asyncio.to_thread( self.workers.resolve, submission.country, submission.policyengine_version ) - policies = [submission.baseline] + ( - [] if submission.reform is None else [submission.reform] + policy_ids = submission.policy_ids() + inputs = await asyncio.to_thread( + lambda: read_household_inputs( + self.reads, + country=submission.country, + household_id=submission.household_id, + policy_ids=policy_ids, + policyengine_version=submission.policyengine_version, + options=submission.options, + ) ) def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: simulations = [ self.store.find_or_create_simulation( - submission.simulation_definition(policy) + submission.simulation_definition(policy_id) ) - for policy in policies + for policy_id in policy_ids ] runs = self._start_side_runs( simulations, - catalog_version_id=submission.catalog_version_id, - year=submission.year, - submission=submission, + inputs=inputs, + year=inputs.year, + trigger=submission.trigger, + force=submission.force, ) report = self.store.create_report( NewReport( @@ -349,7 +465,7 @@ def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: reform_simulation_id=( simulations[1].id if len(simulations) > 1 else None ), - year=submission.year, + year=inputs.year, ) ) return report, runs @@ -358,8 +474,11 @@ def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: return await self._join_and_start( worker, report, - inputs=submission, - sides=list(zip(runs, policies, strict=True)), + inputs=inputs, + sides=[ + (run, inputs.parameters[policy_id]) + for run, policy_id in zip(runs, policy_ids, strict=True) + ], trigger=submission.trigger, ) @@ -368,8 +487,9 @@ async def rerun_report( ) -> HouseholdReport | None: """Start another run of a stored household report; None if there is none. - Raise ReportPoliciesMismatchError when the submission names another - household or other policies, and otherwise as ``submit`` does. + The household, policies and options are the report's simulations'; + the rows are read again for the chosen catalog version. Raise as + ``submit`` does. """ def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: @@ -393,34 +513,44 @@ def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: if loaded is None: return None report, simulations = loaded - policies = [submission.baseline] + ( - [] if submission.reform is None else [submission.reform] - ) - _check_report_policies(simulations, policies, submission.household_id) + household_id = simulations[0].household_id + # The database requires a household simulation to name its household. + if household_id is None: # pragma: no cover + msg = f"household report {report.id} has no household" + raise RuntimeError(msg) + policy_ids = [simulation.policy_id for simulation in simulations] worker = await asyncio.to_thread( self.workers.resolve, report.country, submission.policyengine_version ) + inputs = await asyncio.to_thread( + lambda: read_household_inputs( + self.reads, + country=report.country, + household_id=household_id, + policy_ids=policy_ids, + policyengine_version=submission.policyengine_version, + options=HouseholdCalculationOptions.model_validate( + simulations[0].options + ), + ) + ) runs = await self._write( lambda: self._start_side_runs( simulations, - catalog_version_id=submission.catalog_version_id, + inputs=inputs, year=report.year, - submission=submission, + trigger=submission.trigger, + force=submission.force, ) ) return await self._join_and_start( worker, report, - inputs=ReportRunInputs( - country=report.country, - household=submission.household, - spm=submission.spm, - spm_requested=submission.spm_requested, - options=HouseholdCalculationOptions.model_validate( - simulations[0].options - ), - ), - sides=list(zip(runs, policies, strict=True)), + inputs=inputs, + sides=[ + (run, inputs.parameters[policy_id]) + for run, policy_id in zip(runs, policy_ids, strict=True) + ], trigger=submission.trigger, ) @@ -428,13 +558,14 @@ async def read_report(self, report_id: UUID) -> HouseholdReport | None: """Return a household report with its newest run, or None.""" return await asyncio.to_thread(self.store.read_report, report_id) - def _start_side_runs( + def _start_side_runs( # one keyword per field of the runs self, simulations: Sequence[SimulationRecord], *, - catalog_version_id: UUID, + inputs: HouseholdRunInputs, year: int, - submission: HouseholdReportSubmission | HouseholdReportRunSubmission, + trigger: RunTrigger, + force: bool, ) -> list[SimulationRunRecord]: """Start or reuse a run of each side; a simulation on both sides runs once.""" started: dict[UUID, SimulationRunRecord] = {} @@ -443,11 +574,11 @@ def _start_side_runs( started[simulation.id] = self.store.start_run( NewSimulationRun( simulation_id=simulation.id, - tax_benefit_model_version_id=catalog_version_id, + tax_benefit_model_version_id=inputs.catalog_version_id, year=year, - trigger=submission.trigger, + trigger=trigger, ), - force=submission.force, + force=force, ).run return [started[simulation.id] for simulation in simulations] @@ -457,7 +588,7 @@ async def _join_and_start( report: ReportRecord, *, inputs: HouseholdInputs, - sides: Sequence[tuple[SimulationRunRecord, HouseholdPolicyInput]], + sides: Sequence[tuple[SimulationRunRecord, Mapping[str, object]]], trigger: RunTrigger, ) -> HouseholdReport: """Join the sides' runs in a report run, and start those still pending.""" @@ -473,7 +604,7 @@ async def _join_and_start( ) ) ) - unique_runs = {run.id: (run, policy.parameters) for run, policy in sides} + unique_runs = {run.id: (run, parameters) for run, parameters in sides} await self._start_pending_runs( worker, calculation_id=report_run.id, diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py index f314ee0a0..c7ab6bdf8 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: from collections.abc import Mapping + from pydantic import JsonValue + # Each country's stored group collections and their plural in the situation, # as the Public API's households service accepts them. GROUP_COLLECTIONS: dict[str, dict[str, str]] = { @@ -58,6 +60,6 @@ def household_situation(country: str, document: Mapping[str, Any]) -> dict[str, return situation -def saved_spm_choice(document: Mapping[str, Any]) -> object: +def saved_spm_choice(document: Mapping[str, Any]) -> JsonValue: """Return the household's saved SPM selection, or None when it has none.""" return document.get("spm") diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py index 7d136b2dd..cb7088e04 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -14,11 +14,15 @@ ``GET /v2/household-reports/{report_id}`` (``simulation:read``) returns it with its newest run and that run's two calculations. -Every route answers 503 with ``Retry-After`` when household calculations are -not configured or a dependency cannot be reached. A submission that names a -version or country without a household function, a household, policy or -catalog version the Public API does not have, or, for a report's run, -another household or other policies than the report's, gets 400. +A submission names the Public API's stored household and policies by ID and +the PolicyEngine.py version; sim reads the rows itself and calculates the +household's ``default_year``. Every route answers 503 with ``Retry-After`` +when household calculations are not configured or a dependency, such as the +database holding the Public API's rows, cannot be reached. A submission gets +400, with the reason, when it names a version or country without a +household function, a household, policy or catalog version the Public API +does not have, or a policy changing a parameter the catalog version lacks, +or when the stored household has no default year or cannot be calculated. This is v2 code: the v1 ``run_simulation`` code does not use it. """ @@ -31,6 +35,12 @@ from fastapi import APIRouter, Depends, status from fastapi.responses import JSONResponse +from policyengine_simulation_persistence import ( + PolicyParametersMissingError, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, +) from sqlalchemy.exc import SQLAlchemyError from policyengine_simulation_entry.household_api_models import ( @@ -42,7 +52,7 @@ HouseholdRouteError, ) from policyengine_simulation_entry.household_calculations import ( - ReportPoliciesMismatchError, + HouseholdNotCalculableError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_workers import ( @@ -71,16 +81,20 @@ DATABASE_UNAVAILABLE = "The simulation database could not be reached." REFUSED_RESPONSE = { "description": ( - "No household function for this version and country; an unknown " - "household, policy or catalog version; or, for a report's run, another " - "household or other policies than the report's" + "Refused, with the reason in detail: no household function for this " + "version and country; a household, policy or catalog version the " + "Public API does not have for the country; a policy changing a " + "parameter the catalog version lacks; or a stored household without " + "a default year, or that the household functions cannot take" ), "model": HouseholdRouteError, } UNAVAILABLE_RESPONSE = { "description": ( "Household calculations are not configured, or a dependency could not " - "be reached; retry after Retry-After seconds" + "be reached, such as the v2 version manifest or the database holding " + "the simulation records and the Public API's rows; retry after " + "Retry-After seconds" ), "model": HouseholdRouteError, } @@ -139,7 +153,11 @@ async def guarded[T](operation: Awaitable[T]) -> T | JSONResponse: except ( HouseholdFunctionUnavailableError, UnknownHouseholdReferenceError, - ReportPoliciesMismatchError, + UnknownHouseholdError, + UnknownPolicyError, + UnknownCatalogVersionError, + PolicyParametersMissingError, + HouseholdNotCalculableError, ) as error: return refused(str(error), status.HTTP_400_BAD_REQUEST) except HouseholdWorkersUnavailableError: @@ -187,9 +205,11 @@ def register_household_calculation_routes( CALCULATIONS_PATH, summary="Submit a household calculation", description=( - "Record a household calculation (a household under one policy, for a " - "catalog version and year) and start it. Poll the returned " - "calculation_id until its status is succeeded or failed." + "Record a calculation of a stored household under one stored policy, " + "or current law, on the country's catalog version for a " + "PolicyEngine.py version, for the household's default year, and " + "start it. Poll the returned calculation_id until its status is " + "succeeded or failed." ), operation_id="submit_household_calculation", status_code=status.HTTP_202_ACCEPTED, @@ -262,9 +282,11 @@ def _add_report_creation( REPORTS_PATH, summary="Create a household report", description=( - "Create a household report (the household under a baseline and an " - "optional reform, for a year) and start its first run on a catalog " - "version. Poll the report until its status is succeeded or failed." + "Create a household report (a stored household under a baseline and " + "an optional reform, each a stored policy or current law, for the " + "household's default year) and start its first run on the country's " + "catalog version for a PolicyEngine.py version. Poll the report until " + "its status is succeeded or failed." ), operation_id="submit_household_report", status_code=status.HTTP_202_ACCEPTED, @@ -294,9 +316,11 @@ def _add_report_run( f"{REPORTS_PATH}/{{report_id}}/runs", summary="Run a household report again", description=( - "Start another run of a stored household report on a catalog " - "version, reusing runs that are unfinished or succeeded within the " - "last 28 days unless forced. The answer describes the new run." + "Start another run of a stored household report on the catalog " + "version for a PolicyEngine.py version, reusing runs that are " + "unfinished or succeeded within the last 28 days unless forced. sim " + "reads the report's household and policies again. The answer " + "describes the new run." ), operation_id="run_household_report", status_code=status.HTTP_202_ACCEPTED, diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py index 8378bff67..f1bb964c9 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -7,6 +7,7 @@ from policyengine_simulation_persistence import ( HouseholdCalculationStore, + HouseholdInputReader, RunStore, RunTransitions, SimulationKind, @@ -82,6 +83,7 @@ def household_calculations( """ return HouseholdCalculationService( store=HouseholdCalculationStore(self.engine), + reads=HouseholdInputReader(self.engine), dispatch=RunDispatchService( transitions=RunTransitions(self.engine), dispatcher=ModalCallDispatcher(modal_environment), diff --git a/projects/policyengine-simulation-entry/tests/household_fakes.py b/projects/policyengine-simulation-entry/tests/household_fakes.py index 74257509b..5c4b9dfaa 100644 --- a/projects/policyengine-simulation-entry/tests/household_fakes.py +++ b/projects/policyengine-simulation-entry/tests/household_fakes.py @@ -1,10 +1,13 @@ -"""In-memory household simulations and calculations, for the household tests. +"""In-memory household simulations, calculations and Public API rows, for the tests. ``FakeHouseholdStore`` keeps household simulations and run setups and leaves each run's status to ``FakeRunTransitions``, so the dispatcher, the recorder and the reader all see one copy of every run. It reuses runs by the same rules as ``RunStore``: an unfinished run, or one that succeeded within the reuse window, is reused unless forced; a failed one never is. + +``FakeHouseholdReads`` holds the Public API's rows the household path reads +(``HouseholdInputReader``): catalog versions, households and policies. """ from __future__ import annotations @@ -18,7 +21,9 @@ from policyengine_simulation_persistence import ( SUCCEEDED_RUN_REUSE_WINDOW, HouseholdCalculation, + HouseholdCatalogVersion, HouseholdReport, + PolicyParametersMissingError, ReportKind, ReportRecord, ReportRunRecord, @@ -27,6 +32,10 @@ SimulationRecord, SimulationRunRecord, StartedSimulationRun, + StoredHousehold, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, ) from sqlalchemy.exc import IntegrityError from stage12_fixtures import worker @@ -47,6 +56,141 @@ # The PolicyEngine.py version the fixture manifest's worker runs. POLICYENGINE_VERSION = "5.2.0" CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-00000000ca7a") +UK_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-00000000ca7b") +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-0000000000a1") +POLICY_ID = UUID("00000000-0000-4000-8000-0000000000b1") +DEFAULT_YEAR = 2026 +SPM_CHOICE = {"scenario": "ce_trend", "geography_kind": "national"} +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 3000} +} +# A one-person US household as the Public API stores it. +STORED_US_HOUSEHOLD = { + "people": [ + { + "id": "you", + "values": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}, + "memberships": { + "household": "your household", + "family": "your family", + "tax_unit": "your tax unit", + "spm_unit": "your spm unit", + "marital_unit": "your marital unit", + }, + } + ], + "household": [ + { + "id": "your household", + "values": { + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + }, + } + ], + "family": [{"id": "your family", "values": {}}], + "tax_unit": [{"id": "your tax unit", "values": {}}], + "spm_unit": [{"id": "your spm unit", "values": {}}], + "marital_unit": [{"id": "your marital unit", "values": {}}], +} +# The same household in the situation shape the household functions take. +US_SITUATION = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, + "households": { + "your household": { + "members": ["you"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + } + }, + "families": {"your family": {"members": ["you"]}}, + "tax_units": {"your tax unit": {"members": ["you"]}}, + "spm_units": {"your spm unit": {"members": ["you"]}}, + "marital_units": {"your marital unit": {"members": ["you"]}}, +} + + +class FakeHouseholdReads: + """The Public API's catalog versions, households and policies, by country. + + It holds the stored US household (``HOUSEHOLD_ID``, with ``DEFAULT_YEAR``) + and the US CTC reform (``POLICY_ID``) on ``CATALOG_VERSION_ID``, the + catalog version of ``POLICYENGINE_VERSION``. ``missing_parameters`` names + a policy's parameters the run's version lacks; ``error`` fails every read. + """ + + def __init__(self) -> None: + """Hold the stored US household and reform.""" + self.catalog_versions = { + ("us", POLICYENGINE_VERSION): CATALOG_VERSION_ID, + ("uk", POLICYENGINE_VERSION): UK_CATALOG_VERSION_ID, + } + self.households = { + ("us", HOUSEHOLD_ID): StoredHousehold( + id=HOUSEHOLD_ID, + country="us", + default_year=DEFAULT_YEAR, + document=STORED_US_HOUSEHOLD, + ) + } + self.policies: dict[tuple[str, UUID], dict] = {("us", POLICY_ID): CTC_REFORM} + self.missing_parameters: dict[UUID, tuple[str, ...]] = {} + self.error: Exception | None = None + self.reads: list[str] = [] + + def _read(self, name: str) -> None: + self.reads.append(name) + if self.error is not None: + raise self.error + + def catalog_version( + self, country: str, policyengine_version: str + ) -> HouseholdCatalogVersion: + """Return the catalog version, or raise UnknownCatalogVersionError.""" + self._read("catalog_version") + version_id = self.catalog_versions.get((country, policyengine_version)) + if version_id is None: + msg = ( + f"{country} has no catalog version for PolicyEngine.py " + f"{policyengine_version}" + ) + raise UnknownCatalogVersionError(msg) + return HouseholdCatalogVersion( + id=version_id, country=country, policyengine_version=policyengine_version + ) + + def household(self, country: str, household_id: UUID) -> StoredHousehold: + """Return the household, or raise UnknownHouseholdError.""" + self._read("household") + household = self.households.get((country, household_id)) + if household is None: + msg = f"{country} has no household {household_id}" + raise UnknownHouseholdError(msg) + return household + + def policy_parameters( + self, country: str, policy_id: UUID, catalog_version_id: UUID + ) -> dict: + """Return the policy's values, or raise why the run cannot apply them.""" + del catalog_version_id + self._read("policy_parameters") + parameters = self.policies.get((country, policy_id)) + if parameters is None: + msg = f"{country} has no policy {policy_id}" + raise UnknownPolicyError(msg) + missing = self.missing_parameters.get(policy_id) + if missing: + raise PolicyParametersMissingError(missing) + return parameters + + def store_household(self, default_year: int | None, **document: object) -> None: + """Replace the stored US household's default year or document fields.""" + self.households[("us", HOUSEHOLD_ID)] = StoredHousehold( + id=HOUSEHOLD_ID, + country="us", + default_year=default_year, + document={**STORED_US_HOUSEHOLD, **document}, + ) @dataclass(frozen=True) diff --git a/projects/policyengine-simulation-entry/tests/household_harness.py b/projects/policyengine-simulation-entry/tests/household_harness.py index 5e6efc555..335e76564 100644 --- a/projects/policyengine-simulation-entry/tests/household_harness.py +++ b/projects/policyengine-simulation-entry/tests/household_harness.py @@ -1,18 +1,20 @@ -"""A household calculation service over fake runs, Modal and manifest. +"""A household calculation service over fake runs, Modal, manifest and Public API rows. ``Harness`` wires the real ``HouseholdCalculationService`` and ``RunRecorder`` to ``FakeHouseholdStore``, ``FakeRunTransitions``, a fake -dispatcher and inspector, and the fixture manifest, all on one clock. +dispatcher and inspector, the fixture manifest and ``FakeHouseholdReads``, +all on one clock. """ from __future__ import annotations import asyncio -from uuid import UUID +from typing import TYPE_CHECKING from household_fakes import ( - CATALOG_VERSION_ID, + HOUSEHOLD_ID, POLICYENGINE_VERSION, + FakeHouseholdReads, FakeHouseholdStore, manifest_workers, ) @@ -34,36 +36,20 @@ from policyengine_simulation_persistence import SimulationKind from run_fakes import FakeDispatcher, FakeInspector, FakeRunTransitions, ManualClock -HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-0000000000a1") -POLICY_ID = UUID("00000000-0000-4000-8000-0000000000b1") -US_HOUSEHOLD = { - "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, - "households": { - "your household": { - "members": ["you"], - "state_name": {"2026": "CA"}, - "household_net_income": {"2026": None}, - } - }, -} +if TYPE_CHECKING: + from uuid import UUID + EARNINGS_AXES = [ [{"name": "employment_income", "count": 5, "min": 0, "max": 80_000}], ] -CTC_REFORM = { - "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 3000} -} -SPM_CHOICE = {"scenario": "ce_trend", "geography_kind": "national"} def submission(**overrides: object) -> HouseholdCalculationSubmission: - """Return a 2026 current-law calculation of a one-person US household.""" + """Return a current-law calculation of the stored one-person US household.""" values: dict[str, object] = { "country": "us", - "catalog_version_id": CATALOG_VERSION_ID, "policyengine_version": POLICYENGINE_VERSION, - "year": 2026, "household_id": HOUSEHOLD_ID, - "household": US_HOUSEHOLD, } values.update(overrides) return HouseholdCalculationSubmission.model_validate(values) @@ -79,8 +65,10 @@ def __init__(self, dispatcher: FakeDispatcher | None = None) -> None: self.store = FakeHouseholdStore(self.transitions, self.clock) self.dispatcher = dispatcher or FakeDispatcher() self.inspector = FakeInspector() + self.reads = FakeHouseholdReads() self.service = HouseholdCalculationService( store=self.store, + reads=self.reads, dispatch=RunDispatchService( transitions=self.transitions, dispatcher=self.dispatcher, diff --git a/projects/policyengine-simulation-entry/tests/test_household_calculations.py b/projects/policyengine-simulation-entry/tests/test_household_calculations.py index ccc28d05c..410d53600 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_calculations.py +++ b/projects/policyengine-simulation-entry/tests/test_household_calculations.py @@ -1,4 +1,9 @@ -"""Submitting household calculations, and reading them as their calls end.""" +"""Submitting household calculations, and reading them as their calls end. + +A submission names the stored household, the policy and the PolicyEngine.py +version; sim reads the household, the policy's values and the catalog +version itself, and calculates the household's ``default_year``. +""" from __future__ import annotations @@ -6,20 +11,21 @@ from datetime import timedelta import pytest -from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION -from household_harness import ( +from household_fakes import ( + CATALOG_VERSION_ID, CTC_REFORM, - EARNINGS_AXES, + DEFAULT_YEAR, HOUSEHOLD_ID, POLICY_ID, + POLICYENGINE_VERSION, SPM_CHOICE, - US_HOUSEHOLD, - Harness, - submission, + US_SITUATION, ) +from household_harness import EARNINGS_AXES, Harness, submission from household_support import calculated_household, succeeded_outcome from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_entry.household_calculations import ( + HouseholdNotCalculableError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_recording import policy_key_for_run @@ -34,24 +40,31 @@ from policyengine_simulation_persistence import ( SUCCEEDED_RUN_REUSE_WINDOW, NewSimulationRun, + PolicyParametersMissingError, RunError, RunErrorCode, RunStatus, RunTrigger, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, ) from run_fakes import FakeDispatcher +from sqlalchemy.exc import OperationalError def test_a_submitted_calculation_starts_one_household_call() -> None: harness = Harness() + harness.reads.store_household(DEFAULT_YEAR, spm=SPM_CHOICE) calculation = harness.submit( - spm=SPM_CHOICE, - policy={"policy_id": str(POLICY_ID), "parameters": CTC_REFORM}, + policy_id=str(POLICY_ID), options={"axes": EARNINGS_AXES, "add_missing_variables": True}, ) assert calculation.run.status is RunStatus.RUNNING + assert calculation.run.tax_benefit_model_version_id == CATALOG_VERSION_ID + assert calculation.run.year == DEFAULT_YEAR assert calculation.simulation.policy_id == POLICY_ID assert calculation.simulation.options == { "axes": EARNINGS_AXES, @@ -70,7 +83,9 @@ def test_a_submitted_calculation_starts_one_household_call() -> None: ] assert request.policies[0].parameters == CTC_REFORM situation = request.household.to_situation() - assert situation["people"] == US_HOUSEHOLD["people"] + assert {key: value for key, value in situation.items() if key != "axes"} == ( + US_SITUATION + ) assert situation["axes"] == EARNINGS_AXES assert request.add_missing_variables assert request.spm_requested @@ -131,7 +146,7 @@ def test_an_unsupported_version_writes_nothing() -> None: assert harness.dispatcher.requests == [] -def test_an_unknown_household_is_refused_without_a_call() -> None: +def test_a_household_deleted_while_submitting_is_refused_without_a_call() -> None: harness = Harness() harness.store.unknown_households.add(HOUSEHOLD_ID) @@ -141,6 +156,86 @@ def test_an_unknown_household_is_refused_without_a_call() -> None: assert harness.dispatcher.requests == [] +@pytest.mark.parametrize( + ("overrides", "error"), + [ + ( + {"household_id": "00000000-0000-4000-8000-00000000dead"}, + UnknownHouseholdError, + ), + ({"policy_id": "00000000-0000-4000-8000-00000000dead"}, UnknownPolicyError), + ({"country": "uk"}, UnknownHouseholdError), + ], + ids=["household", "policy", "another-country"], +) +def test_a_household_or_policy_the_public_api_lacks_writes_nothing( + overrides, error +) -> None: + harness = Harness() + + with pytest.raises(error): + harness.submit(**overrides) + + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_a_version_without_a_catalog_version_writes_nothing() -> None: + harness = Harness() + del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] + + with pytest.raises(UnknownCatalogVersionError, match=POLICYENGINE_VERSION): + harness.submit() + + assert harness.store.simulations == {} + + +def test_a_parameter_the_catalog_version_lacks_writes_nothing() -> None: + harness = Harness() + harness.reads.missing_parameters[POLICY_ID] = ( + "gov.irs.credits.retired_credit.amount", + ) + + with pytest.raises(PolicyParametersMissingError, match="retired_credit"): + harness.submit(policy_id=str(POLICY_ID)) + + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_a_household_without_a_default_year_is_not_calculable() -> None: + harness = Harness() + harness.reads.store_household(None) + + with pytest.raises(HouseholdNotCalculableError, match="default year"): + harness.submit() + + assert harness.store.simulations == {} + + +def test_a_stored_household_the_contract_refuses_is_not_calculable() -> None: + harness = Harness() + harness.reads.store_household( + DEFAULT_YEAR, + family=[{"id": "your family", "values": {}}, {"id": "empty", "values": {}}], + ) + + with pytest.raises(HouseholdNotCalculableError, match="empty"): + harness.submit() + + assert harness.store.simulations == {} + + +def test_an_unreadable_public_api_row_is_reported_as_unavailable() -> None: + harness = Harness() + harness.reads.error = OperationalError("SELECT", {}, ConnectionError("down")) + + with pytest.raises(OperationalError): + harness.submit() + + assert harness.store.simulations == {} + + def test_a_refused_call_reads_back_failed() -> None: harness = Harness(FakeDispatcher(failure=RuntimeError("Modal refused"))) @@ -232,8 +327,9 @@ def test_a_calculation_left_dispatching_fails_and_runs_again() -> None: def test_a_saved_null_spm_choice_is_no_choice() -> None: """A stored household's null selection inherits the defaults, as omission does.""" harness = Harness() + harness.reads.store_household(DEFAULT_YEAR, spm=None) - harness.submit(spm=None) + harness.submit() assert "spm" not in harness.dispatcher.requests[0].arguments[0] assert not harness.sent_request().spm_requested diff --git a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py index 2ed08d6ad..1894420c0 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py @@ -7,25 +7,28 @@ import pytest from conftest import FakeBackend, make_settings from fastapi.testclient import TestClient -from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION -from household_harness import CTC_REFORM, HOUSEHOLD_ID, POLICY_ID, US_HOUSEHOLD, Harness +from household_fakes import ( + CATALOG_VERSION_ID, + DEFAULT_YEAR, + HOUSEHOLD_ID, + POLICY_ID, + POLICYENGINE_VERSION, +) +from household_harness import Harness from household_support import calculated_household, succeeded_outcome from policyengine_simulation_entry.app import RunServices, create_app from policyengine_simulation_entry.modal_calls import CallSucceeded REPORTS_PATH = "/v2/household-reports" -REFORM = {"policy_id": str(POLICY_ID), "parameters": CTC_REFORM} +REFORM = {"policy_id": str(POLICY_ID)} def report_body(**overrides: object) -> dict: - """Return the JSON body of a 2026 US report of current law against a reform.""" + """Return the JSON body of a report of current law against the stored reform.""" body: dict[str, object] = { "country": "us", - "catalog_version_id": str(CATALOG_VERSION_ID), "policyengine_version": POLICYENGINE_VERSION, - "year": 2026, "household_id": str(HOUSEHOLD_ID), - "household": US_HOUSEHOLD, "reform": REFORM, } body.update(overrides) @@ -33,14 +36,8 @@ def report_body(**overrides: object) -> dict: def rerun_body(**overrides: object) -> dict: - """Return the JSON body of another run of that report.""" - body: dict[str, object] = { - "catalog_version_id": str(CATALOG_VERSION_ID), - "policyengine_version": POLICYENGINE_VERSION, - "household_id": str(HOUSEHOLD_ID), - "household": US_HOUSEHOLD, - "reform": REFORM, - } + """Return the JSON body of another run of a report.""" + body: dict[str, object] = {"policyengine_version": POLICYENGINE_VERSION} body.update(overrides) return body @@ -71,7 +68,7 @@ def test_a_created_report_is_accepted_with_its_first_run(report_client) -> None: body = response.json() assert body["status"] == "running" assert body["country"] == "us" - assert body["year"] == 2026 + assert body["year"] == DEFAULT_YEAR assert body["household_id"] == str(HOUSEHOLD_ID) assert body["trigger"] == "initial" assert body["catalog_version_id"] == str(CATALOG_VERSION_ID) @@ -116,15 +113,23 @@ def test_a_report_runs_again_on_request(report_client) -> None: assert body["baseline"]["attempt"] == 2 -def test_a_run_with_other_policies_is_refused(report_client) -> None: +@pytest.mark.parametrize( + "overrides", + [ + {"household_id": str(HOUSEHOLD_ID)}, + {"reform": REFORM}, + {"catalog_version_id": str(CATALOG_VERSION_ID)}, + ], + ids=["household", "policies", "catalog-version"], +) +def test_a_run_names_only_its_version(report_client, overrides) -> None: created = report_client.post(REPORTS_PATH, json=report_body()).json() response = report_client.post( - f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(reform=None) + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(**overrides) ) - assert response.status_code == 400 - assert "policies" in response.json()["detail"] + assert response.status_code == 422 def test_an_unknown_report_is_not_found(report_client) -> None: @@ -140,11 +145,12 @@ def test_an_unknown_report_is_not_found(report_client) -> None: def test_an_unknown_household_is_refused(report_client, harness) -> None: - harness.store.unknown_households.add(HOUSEHOLD_ID) + del harness.reads.households[("us", HOUSEHOLD_ID)] response = report_client.post(REPORTS_PATH, json=report_body()) assert response.status_code == 400 + assert "has no household" in response.json()["detail"] def test_without_household_calculations_the_report_routes_are_unavailable() -> None: diff --git a/projects/policyengine-simulation-entry/tests/test_household_reports.py b/projects/policyengine-simulation-entry/tests/test_household_reports.py index e5a3a6f59..c8a933972 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_reports.py +++ b/projects/policyengine-simulation-entry/tests/test_household_reports.py @@ -7,14 +7,15 @@ from uuid import UUID, uuid4 import pytest -from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION -from household_harness import ( +from household_fakes import ( + CATALOG_VERSION_ID, CTC_REFORM, + DEFAULT_YEAR, HOUSEHOLD_ID, POLICY_ID, - US_HOUSEHOLD, - Harness, + POLICYENGINE_VERSION, ) +from household_harness import Harness from household_support import calculated_household, failed_outcome, succeeded_outcome from policyengine_simulation_entry.household_api_models import ( HouseholdReportRunSubmission, @@ -22,28 +23,30 @@ household_report_status, ) from policyengine_simulation_entry.household_calculations import ( - ReportPoliciesMismatchError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_recording import policy_key_for_run from policyengine_simulation_entry.modal_calls import CallSucceeded -from policyengine_simulation_persistence import RunStatus, RunTrigger +from policyengine_simulation_persistence import ( + PolicyParametersMissingError, + RunStatus, + RunTrigger, + UnknownCatalogVersionError, + UnknownPolicyError, +) if TYPE_CHECKING: from policyengine_simulation_entry.run_dispatch import DispatchOutcome -REFORM = {"policy_id": str(POLICY_ID), "parameters": CTC_REFORM} +REFORM = {"policy_id": str(POLICY_ID)} def report_submission(**overrides: object) -> HouseholdReportSubmission: - """Return a 2026 US household report of current law against the CTC reform.""" + """Return a report of the stored US household: current law against the reform.""" values: dict[str, object] = { "country": "us", - "catalog_version_id": CATALOG_VERSION_ID, "policyengine_version": POLICYENGINE_VERSION, - "year": 2026, "household_id": HOUSEHOLD_ID, - "household": US_HOUSEHOLD, "reform": REFORM, } values.update(overrides) @@ -51,14 +54,8 @@ def report_submission(**overrides: object) -> HouseholdReportSubmission: def rerun_submission(**overrides: object) -> HouseholdReportRunSubmission: - """Return another run of the report on the same catalog version.""" - values: dict[str, object] = { - "catalog_version_id": CATALOG_VERSION_ID, - "policyengine_version": POLICYENGINE_VERSION, - "household_id": HOUSEHOLD_ID, - "household": US_HOUSEHOLD, - "reform": REFORM, - } + """Return another run of a report on the same catalog version.""" + values: dict[str, object] = {"policyengine_version": POLICYENGINE_VERSION} values.update(overrides) return HouseholdReportRunSubmission.model_validate(values) @@ -86,6 +83,8 @@ def test_one_call_computes_a_reports_baseline_and_reform() -> None: report = submit_report(harness) baseline_run_id, reform_run_id = report_runs(report) + assert report.report.year == DEFAULT_YEAR + assert report.baseline.run.year == report.reform.run.year == DEFAULT_YEAR assert report.report_run.reform_simulation_run_id == reform_run_id assert report.baseline.simulation.policy_id is None assert report.reform.simulation.policy_id == POLICY_ID @@ -205,24 +204,30 @@ def test_a_report_runs_again_reusing_its_runs_unless_forced() -> None: assert latest.report_run.id == forced.report_run.id -@pytest.mark.parametrize( - "overrides", - [ - {"reform": None}, - {"reform": {"policy_id": str(uuid4()), "parameters": {}}}, - {"baseline": {"policy_id": str(POLICY_ID), "parameters": {}}}, - {"household_id": str(uuid4())}, - ], - ids=["no-reform", "other-reform", "other-baseline", "other-household"], -) -def test_a_run_must_calculate_the_reports_own_policies(overrides) -> None: +def test_a_reports_run_reads_its_own_household_and_policies_again() -> None: harness = Harness() report = submit_report(harness) + harness.reads.reads.clear() + + rerun_report(harness, report.report.id, force=True) + + assert harness.reads.reads == [ + "catalog_version", + "household", + "policy_parameters", + ] + request = harness.sent_request() + assert [policy.parameters for policy in request.policies] == [{}, CTC_REFORM] - with pytest.raises(ReportPoliciesMismatchError): - rerun_report(harness, report.report.id, **overrides) - assert len(harness.dispatcher.requests) == 1 +def test_a_baseline_only_reports_run_calculates_only_its_baseline() -> None: + harness = Harness() + report = submit_report(harness, reform=None) + + again = rerun_report(harness, report.report.id, force=True) + + assert again.reform is None + assert len(harness.sent_request().policies) == 1 def test_an_unknown_report_has_no_runs_to_start() -> None: @@ -232,7 +237,7 @@ def test_an_unknown_report_has_no_runs_to_start() -> None: assert asyncio.run(harness.service.read_report(uuid4())) is None -def test_an_unknown_catalog_version_leaves_no_report_behind() -> None: +def test_a_catalog_version_deleted_while_submitting_leaves_no_report_behind() -> None: harness = Harness() harness.store.unknown_catalog_versions.add(CATALOG_VERSION_ID) @@ -243,11 +248,26 @@ def test_an_unknown_catalog_version_leaves_no_report_behind() -> None: assert harness.dispatcher.requests == [] -def test_a_baseline_only_reports_run_takes_no_reform() -> None: +@pytest.mark.parametrize( + ("change", "error"), + [ + ("unknown-reform", UnknownPolicyError), + ("missing-parameter", PolicyParametersMissingError), + ("no-catalog-version", UnknownCatalogVersionError), + ], +) +def test_a_report_sim_cannot_calculate_leaves_nothing_behind(change, error) -> None: harness = Harness() - report = submit_report(harness, reform=None) - - with pytest.raises(ReportPoliciesMismatchError): - rerun_report(harness, report.report.id) + if change == "unknown-reform": + del harness.reads.policies[("us", POLICY_ID)] + elif change == "missing-parameter": + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + else: + del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] + + with pytest.raises(error): + submit_report(harness) - assert len(harness.dispatcher.requests) == 1 + assert harness.store.reports == {} + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] diff --git a/projects/policyengine-simulation-entry/tests/test_household_routes.py b/projects/policyengine-simulation-entry/tests/test_household_routes.py index 38224268a..834907731 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_household_routes.py @@ -7,14 +7,14 @@ import pytest from conftest import FakeBackend, make_settings from fastapi.testclient import TestClient -from household_fakes import CATALOG_VERSION_ID, POLICYENGINE_VERSION -from household_harness import ( - EARNINGS_AXES, +from household_fakes import ( + CATALOG_VERSION_ID, + DEFAULT_YEAR, HOUSEHOLD_ID, POLICY_ID, - US_HOUSEHOLD, - Harness, + POLICYENGINE_VERSION, ) +from household_harness import EARNINGS_AXES, Harness from household_support import ( BUNDLE, SPM_CONFIG, @@ -33,19 +33,17 @@ ) from policyengine_simulation_entry.modal_calls import CallSucceeded from policyengine_simulation_entry.simulation_database import SimulationDatabase +from sqlalchemy.exc import OperationalError SUBMIT_PATH = "/v2/household-calculations" def submission_body(**overrides: object) -> dict: - """Return the JSON body of a 2026 current-law US calculation.""" + """Return the JSON body of a current-law calculation of the stored household.""" body: dict[str, object] = { "country": "us", - "catalog_version_id": str(CATALOG_VERSION_ID), "policyengine_version": POLICYENGINE_VERSION, - "year": 2026, "household_id": str(HOUSEHOLD_ID), - "household": US_HOUSEHOLD, } body.update(overrides) return body @@ -80,7 +78,7 @@ def test_a_submitted_calculation_is_accepted_and_running(household_client) -> No assert body["policy_id"] is None assert body["options"] == {} assert body["catalog_version_id"] == str(CATALOG_VERSION_ID) - assert body["year"] == 2026 + assert body["year"] == DEFAULT_YEAR assert body["attempt"] == 1 assert body["trigger"] == "initial" assert body["result"] is None @@ -92,10 +90,7 @@ def test_a_succeeded_calculation_reads_back_its_household( ) -> None: submitted = household_client.post( SUBMIT_PATH, - json=submission_body( - policy={"policy_id": str(POLICY_ID), "parameters": {}}, - options={"axes": EARNINGS_AXES}, - ), + json=submission_body(policy_id=str(POLICY_ID), options={"axes": EARNINGS_AXES}), ).json() run_id = submitted["calculation_id"] stored_id = UUID(run_id) @@ -151,12 +146,21 @@ def test_an_unknown_calculation_is_not_found(household_client) -> None: @pytest.mark.parametrize( "overrides", [ - {"household": {**US_HOUSEHOLD, "axes": EARNINGS_AXES}}, - {"policy": {"parameters": {"gov.irs.credits.ctc.refundable": {}}}}, - {"year": 1899}, + {"household": {"people": {}}}, + {"policy": {"policy_id": str(POLICY_ID), "parameters": {}}}, + {"year": 2026}, + {"catalog_version_id": str(CATALOG_VERSION_ID)}, {"country": "ca"}, + {"household_id": "not-an-id"}, + ], + ids=[ + "household-values", + "policy-values", + "year", + "catalog-version", + "country", + "household-id", ], - ids=["axes-in-household", "parameters-without-policy", "year", "country"], ) def test_a_malformed_submission_is_refused_before_anything_runs( household_client, harness, overrides @@ -176,7 +180,9 @@ def test_a_version_without_a_household_function_is_refused(household_client) -> assert "9.9.9" in response.json()["detail"] -def test_an_unknown_household_is_refused(household_client, harness) -> None: +def test_a_household_deleted_while_submitting_is_refused( + household_client, harness +) -> None: harness.store.unknown_households.add(HOUSEHOLD_ID) response = household_client.post(SUBMIT_PATH, json=submission_body()) @@ -187,6 +193,49 @@ def test_an_unknown_household_is_refused(household_client, harness) -> None: ) +@pytest.mark.parametrize( + ("change", "reason"), + [ + ("unknown-household", "has no household"), + ("unknown-policy", "has no policy"), + ("no-catalog-version", "has no catalog version"), + ("missing-parameter", "gov.irs.retired"), + ("no-default-year", "default year"), + ], +) +def test_a_calculation_sim_cannot_run_is_refused_with_why( + household_client, harness, change, reason +) -> None: + body = submission_body(policy_id=str(POLICY_ID)) + if change == "unknown-household": + body["household_id"] = "00000000-0000-4000-8000-00000000dead" + elif change == "unknown-policy": + body["policy_id"] = "00000000-0000-4000-8000-00000000dead" + elif change == "no-catalog-version": + del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] + elif change == "missing-parameter": + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + else: + harness.reads.store_household(None) + + response = household_client.post(SUBMIT_PATH, json=body) + + assert response.status_code == 400 + assert reason in response.json()["detail"] + assert harness.dispatcher.requests == [] + + +def test_an_unreadable_public_api_row_asks_the_caller_to_retry( + household_client, harness +) -> None: + harness.reads.error = OperationalError("SELECT", {}, ConnectionError("down")) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert response.headers["retry-after"] + + class UnreachableWorkers: """Household workers whose manifest cannot be read.""" diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 96b534022..f41510a20 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -45,9 +45,7 @@ class OpenAPIDocument(TypedDict): "HouseholdCalculationResponse", "HouseholdCalculationResult", "HouseholdCalculationSubmission", - "HouseholdDocument", - "HouseholdGroup", - "HouseholdPolicyInput", + "HouseholdPolicyChoice", "HouseholdReportResponse", "HouseholdReportRunSubmission", "HouseholdReportSubmission", diff --git a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py index 3380a887b..633da6cfb 100644 --- a/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py +++ b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py @@ -102,23 +102,15 @@ def _staging_test_module(): return module -def test_the_calculated_household_is_the_stored_one_in_the_situation_shape() -> None: - """The calculation sends the stored household as stored, in its other shape.""" +def test_the_submission_names_the_stored_household_by_id_only() -> None: + """Sim reads the household, its policy and the catalog version itself.""" module = _staging_test_module() - assert module.situation_from_stored(module.STORED_HOUSEHOLD) == { - "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, - "households": { - "your household": { - "members": ["you"], - "state_name": {"2026": "CA"}, - "household_net_income": {"2026": None}, - } - }, - "families": {"your family": {"members": ["you"]}}, - "tax_units": {"your tax unit": {"members": ["you"]}}, - "spm_units": {"your spm unit": {"members": ["you"]}}, - "marital_units": {"your marital unit": {"members": ["you"]}}, + assert module.submission_body("household-id", "6.10.0") == { + "country": "us", + "policyengine_version": "6.10.0", + "household_id": "household-id", + "force": True, } From d4e3be2ace30c5eaea34f72f288594682d4610c3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:56:09 +0400 Subject: [PATCH 121/124] Describe the request year as the periods it sets, and name its helper plainly A review of the request year found no bugs, only wording and coverage: - The request year sets the period of added variables and of values and axes the household gives without one; it does not limit the periods the household names, which are calculated as named. The contract, the calculation module's list of differences and the migration document now say so, instead of calling it the year the household is calculated for. - The stored-household guidance now says the results match the Public API's route only when the household also gives every value and axis a period. - The helper that changes the situation in place is now assign_request_year, and the input module and the prepared household describe the step. - New tests: a single value and a single null are calculated for the request year end to end, a value beside a membership list is put in the year while the list stays a list, and an axis with a null period gets the year. Co-Authored-By: Claude Opus 5.5 --- .../stage-12-modal-worker-foundation.md | 5 +-- .../household_execution.py | 24 +++++++++----- .../household_calculation.py | 17 ++++++---- .../household_inputs.py | 33 +++++++++++-------- .../tests/test_household_calculation.py | 25 ++++++++++++++ .../tests/test_household_inputs.py | 22 +++++++++---- 6 files changed, 89 insertions(+), 37 deletions(-) diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index 59f22c78d..d7f0798c4 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -468,9 +468,10 @@ optional axes, under one to ten policies, so a baseline and its reform are calculated in one call. It takes a `HouseholdExecutionRequest` and returns a `HouseholdExecutionResult` or a typed `HouseholdExecutionFailure` from `policyengine_simulation_contract.household_execution`, as JSON. The request -names the year it calculates, such as a stored household's default year. A +names the run's year, such as a stored household's default year. A calculate-full request adds the household's missing variables for that year, -and an axis or a variable value given without a period is for that year too. +and an axis or a variable value given without a period is for that year too; +every other value is calculated for the period the household names. The Public API instead takes the year for added variables from a person named `you`, or the current year (v2 households name people by record ID), and leaves values and axes without a period to the country model's own default diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py index 1ee1ca87c..90725b033 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py @@ -89,8 +89,9 @@ Field( pattern=r"^[0-9]{4}$", description=( - "The year the household is calculated for, written as a " - "situation's period key, such as '2025'." + "The run's year, written as a situation's period key, such as " + "'2025': the period of added variables and of values and axes " + "the household gives without one." ), ), ] @@ -474,10 +475,13 @@ class HouseholdExecutionRequest(StrictHouseholdModel): """A caller to a Modal household function: calculate one household. ``calculation_id`` is the caller's durable identity for the calculation. - ``year`` is the year the household is calculated for, such as a stored - household's default year, written as a situation's period key (four - digits). ``bundle`` is the bundle the caller resolved from the version - manifest; the household function refuses a request for any other bundle. + ``year`` is the run's year, such as a stored household's default year, + written as a situation's period key (four digits): the period of the + variables ``add_missing_variables`` adds and of any value or axis the + household gives without a period. Every other value is calculated for the + period the household names. ``bundle`` is the bundle the caller resolved + from the version manifest; the household function refuses a request for + any other bundle. ``add_missing_variables`` adds every yearly, monthly and eternal variable the household's entities lack, for ``year``, before calculating, as the @@ -501,9 +505,11 @@ class HouseholdExecutionRequest(StrictHouseholdModel): the household's default year; pass the household's saved SPM selection as ``spm`` only if it has one (a saved null is no choice); and set ``spm_selection_source`` to ``"saved_household"``. When ``year`` is the - year that route would infer, the calculated household, warnings, SPM - receipt and SPM error codes then match that route's. Its error messages - do not: the route reports a household it + year that route would infer and the household gives every value and axis + a period, the calculated household, warnings, SPM receipt and SPM error + codes then match that route's (the executor's household calculation module + lists every difference). Its error messages do not: the route reports a + household it cannot read, and any other failure except an SPM error, as an error calculating that household ID under that policy ID, while a household function reports the request routes' wording (``invalid_household`` and diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py index b4d0c6f8b..bff1d4082 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -25,8 +25,11 @@ - A variable given as one value, not by period, is that value for ``year``, and an axis without a period varies ``year``; the calculated household shows both with that period. The Public API leaves them to the country - model's own default year (2024 for the US, 2025 for the UK), and does not - calculate a variable given as one null value. + model's own default year (currently 2024 for the US model and 2025 for the + UK model), and does not calculate a variable given as one null value. +- ``year`` sets only those periods. Every other value is calculated for the + period the household names, which the function does not compare with + ``year``, as the Public API does not. - An explicit null ``axes`` means no axes, because the contract drops it; the Public API treats the key's presence as a household with axes. - Every policy's parameters are checked before any policy is calculated, so @@ -81,12 +84,12 @@ HouseholdModelMetadata, RequestedValue, add_missing_variables, + assign_request_year, drop_deprecated_inputs, find_unrecognized_household_inputs, find_unrecognized_policy_inputs, requested_computations, unrecognized_inputs_message, - with_request_year, ) from policyengine_simulation_executor.household_spm import ( HOUSEHOLD_SPM_INPUT_ERROR_CODES, @@ -326,8 +329,10 @@ def _serialize_entity_value( class _PreparedHousehold: """A household ready to calculate under each policy. - ``situation`` has its missing variables added and deprecated inputs - removed; ``input_warnings`` say which inputs were removed. + ``situation`` has its values and axes without a period put in the + request's year, its missing variables added for that year and its + deprecated inputs removed; ``input_warnings`` say which inputs were + removed. ``spm_selection`` is the resolved SPM selection (None when no SPM applies) and ``spm_requested`` whether the caller chose it. """ @@ -582,7 +587,7 @@ def _prepared_household( reported with the policy that names them. """ situation = request.household.to_situation() - with_request_year(situation, year=request.year) + assign_request_year(situation, year=request.year) if request.add_missing_variables: add_missing_variables(situation, model.metadata, year=request.year) deprecated = drop_deprecated_inputs(situation) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py index 333a4b04c..67de12a67 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py @@ -5,8 +5,13 @@ which deprecated inputs are dropped (and the warning that says so), which names are reported as unrecognized, and which values are requested. Keeping the wording and order identical lets a v2 result be compared with the Public -API's result field by field. Missing variables are added for the request's -year, where the Public API infers one. +API's result field by field. + +Two steps differ, because a v2 request names its year: missing variables are +added for that year, where the Public API infers one, and +``assign_request_year`` puts values and axes the household gives without a +period in that year, where the Public API leaves them to the country model's +own default year. """ from __future__ import annotations @@ -131,32 +136,32 @@ def _metadata_default_value(variable: Variable) -> bool | int | float | str | No return None -def with_request_year(situation: dict[str, Any], *, year: str) -> None: - """Give ``year`` to every axis and value the household gives without a period. +def assign_request_year(situation: dict[str, Any], *, year: str) -> None: + """Put every value and axis the household gives without a period in ``year``. - A variable given as one value, not by period, becomes that value for - ``year``, and an axis without a ``period`` varies ``year``. The country - model would otherwise put both in its own default year (2024 for the US - model, 2025 for the UK model), as the Public API leaves it to, whatever - year the household is calculated for. Membership lists are left alone. - The situation is changed in place. + Changes ``situation`` in place: a variable given as one value, not by + period, becomes that value for ``year``, and an axis without a ``period`` + varies ``year``. The country model would otherwise put both in its own + default year (currently 2024 for the US model and 2025 for the UK model), + whatever year the request names, which is where the Public API leaves + them. Membership lists are left alone. """ for entity_plural, entity_group in situation.items(): if entity_plural == _AXES_KEY: - _give_axes_the_year(entity_group, year) + _put_axes_in_year(entity_group, year) elif isinstance(entity_group, dict): for variables in entity_group.values(): if isinstance(variables, dict): - _date_single_values(variables, year) + _put_single_values_in_year(variables, year) -def _date_single_values(variables: dict[str, Any], year: str) -> None: +def _put_single_values_in_year(variables: dict[str, Any], year: str) -> None: for variable_name, value in variables.items(): if value is None or isinstance(value, (bool, int, float, str)): variables[variable_name] = {year: value} -def _give_axes_the_year(axes: object, year: str) -> None: +def _put_axes_in_year(axes: object, year: str) -> None: if not isinstance(axes, list): return for group in axes: diff --git a/projects/policyengine-simulation-executor/tests/test_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py index ff4d61211..bbc777adf 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_calculation.py +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -249,6 +249,31 @@ def test_with_axes_a_failed_value_is_one_null_per_point_with_a_warning() -> None ) +def test_single_values_are_calculated_for_the_request_year() -> None: + # A value given once, not by period, is for the request's year, and a + # single null asks for that year's value, which the Public API leaves + # uncalculated. + created: list[dict] = [] + + class RecordingSimulation(_stand_in_simulation({"net_income": np.array([2.5])})): + def __init__(self, **options: object) -> None: + super().__init__(**options) + created.append(self.situation) + + household = { + "people": {"you": {"age": 30}}, + "households": {"home": {"members": ["you"], "net_income": None}}, + } + + outcome = _calculate_with_stand_in(RecordingSimulation, household, year="2024") + + assert isinstance(outcome, HouseholdExecutionResult), outcome + assert created[0]["people"]["you"]["age"] == {"2024": 30} + situation = outcome.output.results[0].household.to_situation() + assert situation["people"]["you"]["age"] == {"2024": 30} + assert situation["households"]["home"]["net_income"] == {"2024": 2.5} + + def test_dates_are_written_as_the_public_api_writes_them() -> None: # The Public API's Flask JSON encoder writes a date as an HTTP date. household = { diff --git a/projects/policyengine-simulation-executor/tests/test_household_inputs.py b/projects/policyengine-simulation-executor/tests/test_household_inputs.py index 2450398cd..9da89a7f3 100644 --- a/projects/policyengine-simulation-executor/tests/test_household_inputs.py +++ b/projects/policyengine-simulation-executor/tests/test_household_inputs.py @@ -10,12 +10,12 @@ HouseholdModelMetadata, HouseholdVariable, add_missing_variables, + assign_request_year, drop_deprecated_inputs, find_unrecognized_household_inputs, find_unrecognized_policy_inputs, requested_computations, unrecognized_inputs_message, - with_request_year, ) @@ -117,23 +117,32 @@ def test_axes_and_values_without_a_period_are_for_the_request_year() -> None: "person-1": {"age": 30, "employment_income": {"2024": 1}}, "person-2": {"is_blind": None}, }, - "households": {"household-1": {"members": ["person-1", "person-2"]}}, + "households": { + "household-1": {"members": ["person-1", "person-2"], "state_name": "TX"} + }, "axes": [ [ {"name": "employment_income", "count": 2, "min": 0, "max": 1}, {"name": "age", "count": 2, "min": 0, "max": 1, "period": "2024"}, - ] + ], + [{"name": "rent", "count": 2, "min": 0, "max": 1, "period": None}], ], } - with_request_year(situation, year="2025") + assign_request_year(situation, year="2025") assert situation == { "people": { "person-1": {"age": {"2025": 30}, "employment_income": {"2024": 1}}, "person-2": {"is_blind": {"2025": None}}, }, - "households": {"household-1": {"members": ["person-1", "person-2"]}}, + # The membership list stays a list beside the group's own value. + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "state_name": {"2025": "TX"}, + } + }, "axes": [ [ { @@ -144,7 +153,8 @@ def test_axes_and_values_without_a_period_are_for_the_request_year() -> None: "period": "2025", }, {"name": "age", "count": 2, "min": 0, "max": 1, "period": "2024"}, - ] + ], + [{"name": "rent", "count": 2, "min": 0, "max": 1, "period": "2025"}], ], } From 3e1a2fcbc5ca6ac4367935665023aed26d50a771 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 28 Oct 2026 17:43:45 +0400 Subject: [PATCH 122/124] Refuse caller data the contract refuses with 422, before writing anything A stored policy value the household request contract refuses, such as an object or a nested list, was only checked when the Modal request was built, after the simulation and run (and a report's rows) were written: the route answered an undeclared 500 and left the run pending. sim now checks each policy's values, the converted household, and the whole household request with placeholder IDs, before anything is written. A parameter the catalog version lacks, a stored policy value or a stored household the contract refuses are the caller's data and get 422 with the reason, declared in the OpenAPI document; an unknown household, policy or catalog version and a household without a default year keep 400. A database role without SELECT on the Public API's tables gets 503 saying so. The runs of a report's re-run take the household's year from the one read, and the household function call takes the read inputs directly. Co-Authored-By: Claude Opus 5.5 --- .../policyengine-simulation-entry/README.md | 14 +- .../household_api_models.py | 20 ++ .../household_calculations.py | 184 +++++++++++------- .../household_routes.py | 66 +++++-- .../tests/test_household_calculations.py | 24 ++- .../tests/test_household_report_routes.py | 16 ++ .../tests/test_household_routes.py | 73 ++++++- .../tests/test_openapi.py | 20 ++ 8 files changed, 316 insertions(+), 101 deletions(-) diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index e2b935576..9909d7082 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -40,11 +40,15 @@ database, the country's catalog version for that PolicyEngine.py version, the household (its document, saved SPM choice and `default_year`, the year calculated) and the policy's parameter values, before writing anything. A household, policy or catalog version the Public API does not have for the -country, a policy changing a parameter the catalog version lacks, and a -household without a default year or that the household functions cannot -take are refused with 400 and the reason. The runtime role needs `SELECT` -on those tables (`libs/policyengine-simulation-persistence`, "Household -calculations"). +country, and a household without a default year, are refused with 400 and +the reason. Then every household and policy value is checked against the +household functions' contract, still before anything is written: a policy +changing a parameter the catalog version lacks, and a stored policy value or +household the contract refuses (such as an object as a value, or a group no +person belongs to), are the caller's data and are refused with 422 and the +reason. The runtime role needs `SELECT` on those tables +(`libs/policyengine-simulation-persistence`, "Household calculations"); +without it, the routes answer 503 saying sim cannot read them. Each calculation is a simulation run of a household simulation (household, policy and options such as axes), so the same calculation submitted again diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py index 4af55675e..bce3b4828 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -346,3 +346,23 @@ class HouseholdRouteError(BaseModel): """Why a household route refused or could not serve a request.""" detail: str + + +class HouseholdRequestFieldError(BaseModel): + """One request field FastAPI's validation refused, and why.""" + + loc: list[str | int] + msg: str + type: str + + +class HouseholdRefusalResponse(BaseModel): + """A refusal of the caller's data (422). + + ``detail`` is text when sim refuses the stored household or policy the + request names, such as a parameter the catalog version lacks, a policy + value or a household the household functions' contract refuses; and one + entry per field when FastAPI's validation refuses the request's fields. + """ + + detail: str | list[HouseholdRequestFieldError] diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index eb696a5b9..ba8ed46f6 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -10,9 +10,11 @@ that PolicyEngine.py version, the stored household (its document, saved SPM choice and ``default_year``, the year calculated) and each policy's parameter values by name, refusing a household, policy or catalog - version the Public API does not have, a parameter the catalog version - lacks, and a household without a default year or that the household - functions cannot take, again before anything is written; + version the Public API does not have or a household without a default + year (400), and then checks every value against the household functions' + contract, refusing a parameter the catalog version lacks, a stored + policy value or household the contract refuses (422, caller data), again + before anything is written; 3. finds or creates the simulation, then reuses its run for that catalog version and year (while unfinished, or for 28 days after it succeeded, ``SUCCEEDED_RUN_REUSE_WINDOW``) or starts the next attempt; @@ -42,11 +44,16 @@ import asyncio from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Annotated, Any, Protocol +from uuid import uuid4 from policyengine_simulation_contract.household_execution import ( + MAXIMUM_POLICY_PARAMETERS, HouseholdDocument, HouseholdExecutionRequest, + ParameterPath, + ParameterPeriod, + ParameterValue, ) from policyengine_simulation_persistence import ( NewReport, @@ -56,7 +63,7 @@ RunStatus, ) from policyengine_simulation_persistence.reports import FIRST_YEAR, LAST_YEAR -from pydantic import ValidationError +from pydantic import Field, TypeAdapter, ValidationError from sqlalchemy.exc import IntegrityError from policyengine_simulation_entry.household_api_models import ( @@ -108,13 +115,35 @@ class UnknownHouseholdReferenceError(LookupError): """ -class HouseholdNotCalculableError(ValueError): - """The stored household has no default year, or the household functions refuse it. +class HouseholdWithoutYearError(ValueError): + """The stored household has no default year in the runs' range to calculate.""" - The message names the household and why. + +class HouseholdDataRefusedError(ValueError): + """A stored household or policy value the household functions' contract refuses. + + These are problems with the caller's data: the message names the + household or policy and each refused field, and why. """ +# The values a policy may set, as the household functions' contract takes them. +POLICY_PARAMETERS: TypeAdapter[dict[str, dict[str, Any]]] = TypeAdapter( + Annotated[ + dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], + Field(max_length=MAXIMUM_POLICY_PARAMETERS), + ] +) + + +def _reasons(error: ValidationError) -> str: + """Return each refused field and why, as ``path: message``.""" + return "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in error.errors() + ) + + class HouseholdCalculationRecords(Protocol): """The ``HouseholdCalculationStore`` reads and writes a submission needs.""" @@ -195,40 +224,6 @@ def resolve(self, country: str, policyengine_version: str) -> HouseholdWorker: ... -class HouseholdInputs(Protocol): - """What a household's Modal call calculates, besides its policies.""" - - @property - def country(self) -> str: - """The household's country.""" - ... - - @property - def year(self) -> int: - """The runs' year: the household's default year.""" - ... - - @property - def household(self) -> HouseholdDocument: - """The household in the situation shape, without axes.""" - ... - - @property - def spm(self) -> JsonValue: - """The household's saved SPM choice, when ``spm_requested``.""" - ... - - @property - def spm_requested(self) -> bool: - """Whether the household has a saved SPM choice.""" - ... - - @property - def options(self) -> HouseholdCalculationOptions: - """The axes and whether missing variables are added.""" - ... - - @dataclass(frozen=True) class HouseholdRunInputs: """What a household's runs calculate, read from the Public API's rows. @@ -248,31 +243,44 @@ class HouseholdRunInputs: parameters: Mapping[UUID | None, Mapping[str, object]] -def _calculable_household(country: str, stored: StoredHousehold) -> HouseholdDocument: - """Return the stored household as the household functions take it. +def _calculable_household( + country: str, stored: StoredHousehold +) -> tuple[HouseholdDocument, int]: + """Return the stored household as the household functions take it, and its year. - Raise HouseholdNotCalculableError when it has no default year in the - runs' range, or the household functions' contract refuses it, such as a - group no person belongs to. + Raise HouseholdWithoutYearError when it has no default year in the runs' + range, and HouseholdDataRefusedError when the household functions' + contract refuses it, such as a group no person belongs to. """ year = stored.default_year if year is None or not FIRST_YEAR <= year <= LAST_YEAR: msg = f"the household {stored.id} has no default year to calculate" - raise HouseholdNotCalculableError(msg) + raise HouseholdWithoutYearError(msg) try: - return HouseholdDocument.model_validate( + document = HouseholdDocument.model_validate( household_situation(country, stored.document) ) except ValidationError as error: - reasons = "; ".join( - f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" - for item in error.errors() - ) - msg = f"the household {stored.id} cannot be calculated: {reasons}" - raise HouseholdNotCalculableError(msg) from None + msg = f"the household {stored.id} cannot be calculated: {_reasons(error)}" + raise HouseholdDataRefusedError(msg) from None except (KeyError, TypeError) as error: msg = f"the household {stored.id} cannot be calculated: {error!r}" - raise HouseholdNotCalculableError(msg) from None + raise HouseholdDataRefusedError(msg) from None + return document, year + + +def _checked_policy_parameters( + policy_id: UUID, parameters: Mapping[str, object] +) -> dict[str, dict[str, Any]]: + """Return a policy's values; raise HouseholdDataRefusedError for one refused.""" + try: + return POLICY_PARAMETERS.validate_python(parameters) + except ValidationError as error: + msg = ( + f"the policy {policy_id} sets values the household functions " + f"cannot take: {_reasons(error)}" + ) + raise HouseholdDataRefusedError(msg) from None def read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calculates @@ -287,25 +295,24 @@ def read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calcu """Read and check everything the household's runs calculate. Raise the reads' errors for a catalog version, household or policy the - Public API does not have or a parameter the version lacks, and - HouseholdNotCalculableError for a household the runs cannot calculate. + Public API does not have or a parameter the version lacks, + HouseholdWithoutYearError for a household without a default year, and + HouseholdDataRefusedError for a household or policy value the household + functions' contract refuses. """ version = reads.catalog_version(country, policyengine_version) stored = reads.household(country, household_id) - household = _calculable_household(country, stored) + household, year = _calculable_household(country, stored) spm = saved_spm_choice(stored.document) parameters: dict[UUID | None, Mapping[str, object]] = {} for policy_id in dict.fromkeys(policy_ids): parameters[policy_id] = ( {} if policy_id is None - else reads.policy_parameters(country, policy_id, version.id) + else _checked_policy_parameters( + policy_id, reads.policy_parameters(country, policy_id, version.id) + ) ) - # Checked in _calculable_household. - year = stored.default_year - if year is None: # pragma: no cover - msg = "a calculable household has a default year" - raise RuntimeError(msg) return HouseholdRunInputs( country=country, catalog_version_id=version.id, @@ -319,11 +326,35 @@ def read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calcu ) +def check_household_request( + worker: HouseholdWorker, inputs: HouseholdRunInputs +) -> None: + """Check the household function call these inputs make, before writing anything. + + The call is built as it will be sent, with placeholder IDs for the runs + not created yet, so whatever the contract refuses in the household, its + options, SPM choice or policies is refused now. Raise + HouseholdDataRefusedError naming each refused field. + """ + try: + household_modal_request( + worker, + calculation_id=uuid4(), + inputs=inputs, + run_policies=[ + (uuid4(), parameters) for parameters in inputs.parameters.values() + ], + ) + except ValidationError as error: + msg = f"the household and its policies cannot be calculated: {_reasons(error)}" + raise HouseholdDataRefusedError(msg) from None + + def household_modal_request( worker: HouseholdWorker, *, calculation_id: UUID, - inputs: HouseholdInputs, + inputs: HouseholdRunInputs, run_policies: Sequence[tuple[UUID, Mapping[str, object]]], ) -> ModalRequest: """Return the household function call that computes each run's policy. @@ -378,8 +409,9 @@ async def submit( ) -> HouseholdCalculation: """Record the calculation, start its call if needed, and return it. - Raise HouseholdFunctionUnavailableError or HouseholdWorkersUnavailableError - and the row reads' refusals before anything is written, and + Raise HouseholdFunctionUnavailableError or HouseholdWorkersUnavailableError, + the row reads' refusals and HouseholdDataRefusedError before anything + is written, and UnknownHouseholdReferenceError when the database refuses a reference. """ worker = await asyncio.to_thread( @@ -395,6 +427,7 @@ async def submit( options=submission.options, ) ) + check_household_request(worker, inputs) def start() -> StartedSimulationRun: simulation = self.store.find_or_create_simulation( @@ -450,6 +483,7 @@ async def submit_report( options=submission.options, ) ) + check_household_request(worker, inputs) def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: simulations = [ @@ -461,7 +495,6 @@ def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: runs = self._start_side_runs( simulations, inputs=inputs, - year=inputs.year, trigger=submission.trigger, force=submission.force, ) @@ -542,11 +575,13 @@ def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: ), ) ) + check_household_request(worker, inputs) + # The report's year is its household's default year, which never + # changes, so the runs take the year the household was read with. runs = await self._write( lambda: self._start_side_runs( simulations, inputs=inputs, - year=report.year, trigger=submission.trigger, force=submission.force, ) @@ -566,12 +601,11 @@ async def read_report(self, report_id: UUID) -> HouseholdReport | None: """Return a household report with its newest run, or None.""" return await asyncio.to_thread(self.store.read_report, report_id) - def _start_side_runs( # one keyword per field of the runs + def _start_side_runs( self, simulations: Sequence[SimulationRecord], *, inputs: HouseholdRunInputs, - year: int, trigger: RunTrigger, force: bool, ) -> list[SimulationRunRecord]: @@ -583,7 +617,7 @@ def _start_side_runs( # one keyword per field of the runs NewSimulationRun( simulation_id=simulation.id, tax_benefit_model_version_id=inputs.catalog_version_id, - year=year, + year=inputs.year, trigger=trigger, ), force=force, @@ -595,7 +629,7 @@ async def _join_and_start( worker: HouseholdWorker, report: ReportRecord, *, - inputs: HouseholdInputs, + inputs: HouseholdRunInputs, sides: Sequence[tuple[SimulationRunRecord, Mapping[str, object]]], trigger: RunTrigger, ) -> HouseholdReport: @@ -632,7 +666,7 @@ async def _start_pending_runs( worker: HouseholdWorker, *, calculation_id: UUID, - inputs: HouseholdInputs, + inputs: HouseholdRunInputs, runs: Sequence[tuple[SimulationRunRecord, Mapping[str, object]]], ) -> None: """Start one call for the runs that are pending, whoever created them. diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py index cb7088e04..037423b29 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -21,8 +21,13 @@ database holding the Public API's rows, cannot be reached. A submission gets 400, with the reason, when it names a version or country without a household function, a household, policy or catalog version the Public API -does not have, or a policy changing a parameter the catalog version lacks, -or when the stored household has no default year or cannot be calculated. +does not have, or when the stored household has no default year. It gets +422, with the reason, for the caller's data the household functions cannot +take, checked before anything is written: a policy changing a parameter the +catalog version lacks, or a stored policy value or household the household +functions' contract refuses, such as an object as a value or a group no +person belongs to. Without ``SELECT`` on the Public API's tables, the routes +answer 503 saying sim cannot read them. This is v2 code: the v1 ``run_simulation`` code does not use it. """ @@ -41,18 +46,20 @@ UnknownHouseholdError, UnknownPolicyError, ) -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import ProgrammingError, SQLAlchemyError from policyengine_simulation_entry.household_api_models import ( HouseholdCalculationResponse, HouseholdCalculationSubmission, + HouseholdRefusalResponse, HouseholdReportResponse, HouseholdReportRunSubmission, HouseholdReportSubmission, HouseholdRouteError, ) from policyengine_simulation_entry.household_calculations import ( - HouseholdNotCalculableError, + HouseholdDataRefusedError, + HouseholdWithoutYearError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_workers import ( @@ -79,22 +86,37 @@ NOT_CONFIGURED = "Household calculations are not configured." WORKERS_UNAVAILABLE = "The v2 version manifest could not be read." DATABASE_UNAVAILABLE = "The simulation database could not be reached." +NO_READ_ACCESS = ( + "The Simulation Entrypoint cannot read the Public API's tables: its database " + "role lacks SELECT on them." +) +# PostgreSQL's SQLSTATE for a missing privilege. +INSUFFICIENT_PRIVILEGE = "42501" REFUSED_RESPONSE = { "description": ( "Refused, with the reason in detail: no household function for this " "version and country; a household, policy or catalog version the " - "Public API does not have for the country; a policy changing a " - "parameter the catalog version lacks; or a stored household without " - "a default year, or that the household functions cannot take" + "Public API does not have for the country; or a stored household " + "without a default year" ), "model": HouseholdRouteError, } +CALLER_DATA_REFUSED_RESPONSE = { + "description": ( + "The caller's data cannot be calculated, with the reason in detail " + "as text: a policy changing a parameter the catalog version lacks, or " + "a stored policy value or household the household functions' " + "contract refuses; or a request field is invalid (one entry per field)" + ), + "model": HouseholdRefusalResponse, +} UNAVAILABLE_RESPONSE = { "description": ( "Household calculations are not configured, or a dependency could not " "be reached, such as the v2 version manifest or the database holding " - "the simulation records and the Public API's rows; retry after " - "Retry-After seconds" + "the simulation records and the Public API's rows, or sim's database " + "role cannot read the Public API's tables; retry after Retry-After " + "seconds" ), "model": HouseholdRouteError, } @@ -156,13 +178,22 @@ async def guarded[T](operation: Awaitable[T]) -> T | JSONResponse: UnknownHouseholdError, UnknownPolicyError, UnknownCatalogVersionError, - PolicyParametersMissingError, - HouseholdNotCalculableError, + HouseholdWithoutYearError, ) as error: return refused(str(error), status.HTTP_400_BAD_REQUEST) + except (PolicyParametersMissingError, HouseholdDataRefusedError) as error: + return refused(str(error), status.HTTP_422_UNPROCESSABLE_ENTITY) except HouseholdWorkersUnavailableError: logger.warning("household_manifest_unavailable") return unavailable(WORKERS_UNAVAILABLE) + except ProgrammingError as error: + if getattr(error.orig, "sqlstate", None) != INSUFFICIENT_PRIVILEGE: + raise + logger.error( # noqa: TRY400 # the log names the missing grant; a traceback adds nothing + "household_public_api_tables_unreadable", + extra={"error_type": type(error).__name__}, + ) + return unavailable(NO_READ_ACCESS) except SQLAlchemyError as error: logger.warning( "household_database_unavailable", @@ -214,7 +245,11 @@ def register_household_calculation_routes( operation_id="submit_household_calculation", status_code=status.HTTP_202_ACCEPTED, response_model=HouseholdCalculationResponse, - responses={400: REFUSED_RESPONSE, 503: UNAVAILABLE_RESPONSE}, + responses={ + 400: REFUSED_RESPONSE, + 422: CALLER_DATA_REFUSED_RESPONSE, + 503: UNAVAILABLE_RESPONSE, + }, dependencies=[Depends(submitter)], ) async def submit_household_calculation( @@ -291,7 +326,11 @@ def _add_report_creation( operation_id="submit_household_report", status_code=status.HTTP_202_ACCEPTED, response_model=HouseholdReportResponse, - responses={400: REFUSED_RESPONSE, 503: UNAVAILABLE_RESPONSE}, + responses={ + 400: REFUSED_RESPONSE, + 422: CALLER_DATA_REFUSED_RESPONSE, + 503: UNAVAILABLE_RESPONSE, + }, dependencies=[Depends(submitter)], ) async def submit_household_report( @@ -328,6 +367,7 @@ def _add_report_run( responses={ 400: REFUSED_RESPONSE, 404: NOT_FOUND_RESPONSE, + 422: CALLER_DATA_REFUSED_RESPONSE, 503: UNAVAILABLE_RESPONSE, }, dependencies=[Depends(submitter)], diff --git a/projects/policyengine-simulation-entry/tests/test_household_calculations.py b/projects/policyengine-simulation-entry/tests/test_household_calculations.py index a1870e220..4a310dccd 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_calculations.py +++ b/projects/policyengine-simulation-entry/tests/test_household_calculations.py @@ -25,7 +25,8 @@ from household_support import calculated_household, succeeded_outcome from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_entry.household_calculations import ( - HouseholdNotCalculableError, + HouseholdDataRefusedError, + HouseholdWithoutYearError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_recording import policy_key_for_run @@ -207,7 +208,7 @@ def test_a_household_without_a_default_year_is_not_calculable() -> None: harness = Harness() harness.reads.store_household(None) - with pytest.raises(HouseholdNotCalculableError, match="default year"): + with pytest.raises(HouseholdWithoutYearError, match="default year"): harness.submit() assert harness.store.simulations == {} @@ -220,12 +221,29 @@ def test_a_stored_household_the_contract_refuses_is_not_calculable() -> None: family=[{"id": "your family", "values": {}}, {"id": "empty", "values": {}}], ) - with pytest.raises(HouseholdNotCalculableError, match="empty"): + with pytest.raises(HouseholdDataRefusedError, match="empty"): harness.submit() assert harness.store.simulations == {} +@pytest.mark.parametrize( + "value", [{"nested": 1}, [[1, 2]]], ids=["object", "nested-list"] +) +def test_a_stored_policy_value_the_contract_refuses_writes_nothing(value) -> None: + harness = Harness() + harness.reads.policies[("us", POLICY_ID)] = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": value} + } + + with pytest.raises(HouseholdDataRefusedError, match=str(POLICY_ID)): + harness.submit(policy_id=str(POLICY_ID)) + + assert harness.store.simulations == {} + assert harness.store.setups == {} + assert harness.dispatcher.requests == [] + + def test_an_unreadable_public_api_row_is_reported_as_unavailable() -> None: harness = Harness() harness.reads.error = OperationalError("SELECT", {}, ConnectionError("down")) diff --git a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py index 1894420c0..e4e728615 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py @@ -165,3 +165,19 @@ def test_without_household_calculations_the_report_routes_are_unavailable() -> N read = test_client.get(f"{REPORTS_PATH}/{uuid4()}") assert {created.status_code, rerun.status_code, read.status_code} == {503} + + +def test_a_report_whose_reform_value_the_contract_refuses_leaves_nothing( + report_client, harness +) -> None: + harness.reads.policies[("us", POLICY_ID)] = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": [[1]]} + } + + response = report_client.post(REPORTS_PATH, json=report_body()) + + assert response.status_code == 422 + assert harness.store.reports == {} + assert harness.store.report_runs == [] + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] diff --git a/projects/policyengine-simulation-entry/tests/test_household_routes.py b/projects/policyengine-simulation-entry/tests/test_household_routes.py index 834907731..44aef6ebd 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_household_routes.py @@ -33,7 +33,7 @@ ) from policyengine_simulation_entry.modal_calls import CallSucceeded from policyengine_simulation_entry.simulation_database import SimulationDatabase -from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import OperationalError, ProgrammingError SUBMIT_PATH = "/v2/household-calculations" @@ -199,11 +199,10 @@ def test_a_household_deleted_while_submitting_is_refused( ("unknown-household", "has no household"), ("unknown-policy", "has no policy"), ("no-catalog-version", "has no catalog version"), - ("missing-parameter", "gov.irs.retired"), ("no-default-year", "default year"), ], ) -def test_a_calculation_sim_cannot_run_is_refused_with_why( +def test_a_reference_sim_cannot_resolve_is_refused_with_why( household_client, harness, change, reason ) -> None: body = submission_body(policy_id=str(POLICY_ID)) @@ -213,8 +212,6 @@ def test_a_calculation_sim_cannot_run_is_refused_with_why( body["policy_id"] = "00000000-0000-4000-8000-00000000dead" elif change == "no-catalog-version": del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] - elif change == "missing-parameter": - harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) else: harness.reads.store_household(None) @@ -222,6 +219,50 @@ def test_a_calculation_sim_cannot_run_is_refused_with_why( assert response.status_code == 400 assert reason in response.json()["detail"] + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ("missing-parameter", "gov.irs.retired"), + ("policy-object-value", "gov.irs.credits.ctc.amount.base[0].amount"), + ("policy-nested-list-value", "gov.irs.credits.ctc.amount.base[0].amount"), + ("group-without-members", "empty"), + ], +) +def test_caller_data_the_contract_refuses_gets_422_and_writes_nothing( + household_client, harness, change, reason +) -> None: + ctc = "gov.irs.credits.ctc.amount.base[0].amount" + if change == "missing-parameter": + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + elif change == "policy-object-value": + harness.reads.policies[("us", POLICY_ID)] = { + ctc: {"2026-01-01.2100-12-31": {"nested": 1}} + } + elif change == "policy-nested-list-value": + harness.reads.policies[("us", POLICY_ID)] = { + ctc: {"2026-01-01.2100-12-31": [[1, 2]]} + } + else: + harness.reads.store_household( + DEFAULT_YEAR, + family=[ + {"id": "your family", "values": {}}, + {"id": "empty", "values": {}}, + ], + ) + + response = household_client.post( + SUBMIT_PATH, json=submission_body(policy_id=str(POLICY_ID)) + ) + + assert response.status_code == 422 + assert reason in response.json()["detail"] + assert harness.store.simulations == {} + assert harness.store.setups == {} assert harness.dispatcher.requests == [] @@ -289,3 +330,25 @@ def test_the_simulation_database_builds_the_household_service() -> None: assert isinstance(service, HouseholdCalculationService) assert isinstance(service.workers, ManifestHouseholdWorkers) + + +class InsufficientPrivilegeError(Exception): + """A psycopg error for a missing privilege (SQLSTATE 42501).""" + + sqlstate = "42501" + + +def test_missing_read_access_to_the_public_api_tables_is_named( + household_client, harness +) -> None: + harness.reads.error = ProgrammingError( + "SELECT", + {}, + InsufficientPrivilegeError("permission denied for table households"), + ) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert "lacks SELECT" in response.json()["detail"] + assert response.headers["retry-after"] diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index f41510a20..d19feadd0 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -46,7 +46,9 @@ class OpenAPIDocument(TypedDict): "HouseholdCalculationResult", "HouseholdCalculationSubmission", "HouseholdPolicyChoice", + "HouseholdRefusalResponse", "HouseholdReportResponse", + "HouseholdRequestFieldError", "HouseholdReportRunSubmission", "HouseholdReportSubmission", "HouseholdRouteError", @@ -231,3 +233,21 @@ def test_household_routes_are_public_with_stable_operation_ids() -> None: } assert household_operations == HOUSEHOLD_ROUTES + + +def test_household_submissions_declare_their_caller_data_refusals() -> None: + """The generated client parses sim's 422 refusals as text or field errors.""" + spec = cast("OpenAPIDocument", create_app().openapi()) + + for method, path in ( + ("post", "/v2/household-calculations"), + ("post", "/v2/household-reports"), + ("post", "/v2/household-reports/{report_id}/runs"), + ): + responses = spec["paths"][path][method]["responses"] + refusal = responses["422"] + assert refusal["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/HouseholdRefusalResponse" + } + assert "parameter the catalog version lacks" in refusal["description"] + assert {"400", "503"} <= set(responses) From 4fed38b28de24c255357e196e7472578611f7b4a Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 28 Oct 2026 17:45:09 +0400 Subject: [PATCH 123/124] Require every column the household reads select to be recorded The check that the tests' copies of the Public API's tables match the recorded columns covered only recorded columns, so a column added to a read later could be copied with any type unnoticed. A second check now requires every column in the household reads' tables to be in the recorded description. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_public_api_copies.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py b/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py index 67b79d13a..21c3810ff 100644 --- a/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py +++ b/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py @@ -14,11 +14,12 @@ from pathlib import Path import pytest +from policyengine_simulation_persistence.household_reads import ( + household_read_metadata, +) from public_api_support import superuser_engine from sqlalchemy import text -pytestmark = pytest.mark.postgres - RECORDED_COLUMNS = ( Path(__file__).parent / "fixtures" / "public_api" / "household_read_columns.json" ) @@ -54,6 +55,7 @@ def _copied(superuser_url: str, tables: list[str]) -> dict[str, dict[str, dict]] return copied +@pytest.mark.postgres def test_each_column_the_household_reads_select_matches_the_public_api( disposable_database, ) -> None: @@ -69,3 +71,17 @@ def test_each_column_the_household_reads_select_matches_the_public_api( if copied.get(table, {}).get(column) != description ] assert mismatches == [] + + +def test_every_column_the_household_reads_select_is_recorded() -> None: + """A column added to a read must be recorded, so its copy is checked too.""" + recorded = _recorded() + + unrecorded = [ + f"{table.name}.{column.name}" + for table in household_read_metadata.tables.values() + for column in table.columns + if column.name not in recorded.get(table.name, {}) + ] + + assert unrecorded == [] From 3a2bbe02342b648eaa468f8f543bf6b95ec96d2f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 29 Oct 2026 02:30:32 +0400 Subject: [PATCH 124/124] Cap refusals, and name a missing privilege only for the Public API's tables A refusal of a stored household names at most ten refused fields, and of a policy at most ten refused parameters, checked with the contract's own policy model instead of a copy of its type. A missing privilege is reported as the runtime role lacking SELECT on the Public API's tables only when it comes from reading them; any other database programming error, including a missing privilege on sim's own tables, is answered 503 as an unreachable database again. The whole household request is checked off the event loop. The README and module docstring give the order refusals are found in, and a test covers a report's re-run on a version lacking a parameter: 422, with no run or report run written. Co-Authored-By: Claude Opus 5.5 --- .../policyengine-simulation-entry/README.md | 18 +-- .../household_calculations.py | 113 +++++++++++++----- .../household_routes.py | 18 +-- .../tests/test_household_report_routes.py | 18 +++ .../tests/test_household_routes.py | 59 +++++++++ 5 files changed, 175 insertions(+), 51 deletions(-) diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 9909d7082..557c082e5 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -39,14 +39,16 @@ version; this service reads, from the Public API's tables in the same database, the country's catalog version for that PolicyEngine.py version, the household (its document, saved SPM choice and `default_year`, the year calculated) and the policy's parameter values, before writing anything. -A household, policy or catalog version the Public API does not have for the -country, and a household without a default year, are refused with 400 and -the reason. Then every household and policy value is checked against the -household functions' contract, still before anything is written: a policy -changing a parameter the catalog version lacks, and a stored policy value or -household the contract refuses (such as an object as a value, or a group no -person belongs to), are the caller's data and are refused with 422 and the -reason. The runtime role needs `SELECT` on those tables +Every household and policy value is checked against the household +functions' contract too, all before anything is written. A household, policy +or catalog version the Public API does not have for the country, and a +household without a default year, are refused with 400 and the reason; a +policy changing a parameter the catalog version lacks, and a stored policy +value or household the contract refuses (such as an object as a value, or a +group no person belongs to), are the caller's data and are refused with 422 +and the reason, naming at most ten fields or parameters. The first problem +found is the one answered, in the order the rows are read: the catalog +version, the household, then each policy. The runtime role needs `SELECT` on those tables (`libs/policyengine-simulation-persistence`, "Household calculations"); without it, the routes answer 503 saying sim cannot read them. diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py index ba8ed46f6..4537d792c 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -9,12 +9,13 @@ 2. reads, from the Public API's tables, the country's catalog version for that PolicyEngine.py version, the stored household (its document, saved SPM choice and ``default_year``, the year calculated) and each policy's - parameter values by name, refusing a household, policy or catalog - version the Public API does not have or a household without a default - year (400), and then checks every value against the household functions' - contract, refusing a parameter the catalog version lacks, a stored - policy value or household the contract refuses (422, caller data), again - before anything is written; + parameter values by name, and checks every value against the household + functions' contract, all before anything is written. It refuses, in the + order it reads them: a catalog version or household the Public API does + not have, or a household without a default year (400); a stored + household the contract refuses (422, caller data); then, for each + policy, one the Public API does not have (400), or a parameter the + catalog version lacks or a value the contract refuses (422); 3. finds or creates the simulation, then reuses its run for that catalog version and year (while unfinished, or for 28 days after it succeeded, ``SUCCEEDED_RUN_REUSE_WINDOW``) or starts the next attempt; @@ -44,16 +45,13 @@ import asyncio from dataclasses import dataclass -from typing import TYPE_CHECKING, Annotated, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol from uuid import uuid4 from policyengine_simulation_contract.household_execution import ( - MAXIMUM_POLICY_PARAMETERS, HouseholdDocument, HouseholdExecutionRequest, - ParameterPath, - ParameterPeriod, - ParameterValue, + HouseholdPolicy, ) from policyengine_simulation_persistence import ( NewReport, @@ -63,8 +61,8 @@ RunStatus, ) from policyengine_simulation_persistence.reports import FIRST_YEAR, LAST_YEAR -from pydantic import Field, TypeAdapter, ValidationError -from sqlalchemy.exc import IntegrityError +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError, ProgrammingError from policyengine_simulation_entry.household_api_models import ( HouseholdCalculationOptions, @@ -127,21 +125,25 @@ class HouseholdDataRefusedError(ValueError): """ -# The values a policy may set, as the household functions' contract takes them. -POLICY_PARAMETERS: TypeAdapter[dict[str, dict[str, Any]]] = TypeAdapter( - Annotated[ - dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], - Field(max_length=MAXIMUM_POLICY_PARAMETERS), - ] -) +class PublicApiTablesUnreadableError(RuntimeError): + """The runtime role lacks ``SELECT`` on the Public API's tables sim reads.""" + + +# PostgreSQL's SQLSTATE for a missing privilege. +INSUFFICIENT_PRIVILEGE = "42501" +# A refusal names at most this many fields. +MAX_LISTED_REASONS = 10 def _reasons(error: ValidationError) -> str: - """Return each refused field and why, as ``path: message``.""" - return "; ".join( + """Return each refused field and why, as ``path: message``, at most ten.""" + items = error.errors() + shown = "; ".join( f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" - for item in error.errors() + for item in items[:MAX_LISTED_REASONS] ) + more = len(items) - MAX_LISTED_REASONS + return f"{shown}; and {more} more" if more > 0 else shown class HouseholdCalculationRecords(Protocol): @@ -272,13 +274,33 @@ def _calculable_household( def _checked_policy_parameters( policy_id: UUID, parameters: Mapping[str, object] ) -> dict[str, dict[str, Any]]: - """Return a policy's values; raise HouseholdDataRefusedError for one refused.""" + """Return a policy's values; raise HouseholdDataRefusedError for one refused. + + The values are checked as the household functions' contract checks a + policy, under the key its run will have. The refusal names the refused + parameters, at most ten. + """ try: - return POLICY_PARAMETERS.validate_python(parameters) + return HouseholdPolicy.model_validate( + {"policy_key": policy_key_for_run(policy_id), "parameters": parameters} + ).parameters except ValidationError as error: + refused = sorted( + { + str(item["loc"][1]) + for item in error.errors() + if len(item["loc"]) > 1 and item["loc"][0] == "parameters" + } + ) + if refused: + shown = ", ".join(refused[:MAX_LISTED_REASONS]) + more = len(refused) - MAX_LISTED_REASONS + reasons = f"{shown} and {more} more" if more > 0 else shown + else: + reasons = _reasons(error) msg = ( f"the policy {policy_id} sets values the household functions " - f"cannot take: {_reasons(error)}" + f"cannot take: {reasons}" ) raise HouseholdDataRefusedError(msg) from None @@ -296,10 +318,39 @@ def read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calcu Raise the reads' errors for a catalog version, household or policy the Public API does not have or a parameter the version lacks, - HouseholdWithoutYearError for a household without a default year, and + HouseholdWithoutYearError for a household without a default year, HouseholdDataRefusedError for a household or policy value the household - functions' contract refuses. + functions' contract refuses, and PublicApiTablesUnreadableError when the + runtime role cannot read the Public API's tables. """ + try: + return _read_household_inputs( + reads, + country=country, + household_id=household_id, + policy_ids=policy_ids, + policyengine_version=policyengine_version, + options=options, + ) + except ProgrammingError as error: + if getattr(error.orig, "sqlstate", None) != INSUFFICIENT_PRIVILEGE: + raise + msg = ( + "The Simulation Entrypoint cannot read the Public API's tables: its " + "database role lacks SELECT on them." + ) + raise PublicApiTablesUnreadableError(msg) from error + + +def _read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calculates + reads: HouseholdRowReader, + *, + country: str, + household_id: UUID, + policy_ids: Sequence[UUID | None], + policyengine_version: str, + options: HouseholdCalculationOptions, +) -> HouseholdRunInputs: version = reads.catalog_version(country, policyengine_version) stored = reads.household(country, household_id) household, year = _calculable_household(country, stored) @@ -427,7 +478,7 @@ async def submit( options=submission.options, ) ) - check_household_request(worker, inputs) + await asyncio.to_thread(check_household_request, worker, inputs) def start() -> StartedSimulationRun: simulation = self.store.find_or_create_simulation( @@ -483,7 +534,7 @@ async def submit_report( options=submission.options, ) ) - check_household_request(worker, inputs) + await asyncio.to_thread(check_household_request, worker, inputs) def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: simulations = [ @@ -575,7 +626,7 @@ def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: ), ) ) - check_household_request(worker, inputs) + await asyncio.to_thread(check_household_request, worker, inputs) # The report's year is its household's default year, which never # changes, so the runs take the year the household was read with. runs = await self._write( diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py index 037423b29..bfdc6a22d 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -46,7 +46,7 @@ UnknownHouseholdError, UnknownPolicyError, ) -from sqlalchemy.exc import ProgrammingError, SQLAlchemyError +from sqlalchemy.exc import SQLAlchemyError from policyengine_simulation_entry.household_api_models import ( HouseholdCalculationResponse, @@ -60,6 +60,7 @@ from policyengine_simulation_entry.household_calculations import ( HouseholdDataRefusedError, HouseholdWithoutYearError, + PublicApiTablesUnreadableError, UnknownHouseholdReferenceError, ) from policyengine_simulation_entry.household_workers import ( @@ -86,12 +87,7 @@ NOT_CONFIGURED = "Household calculations are not configured." WORKERS_UNAVAILABLE = "The v2 version manifest could not be read." DATABASE_UNAVAILABLE = "The simulation database could not be reached." -NO_READ_ACCESS = ( - "The Simulation Entrypoint cannot read the Public API's tables: its database " - "role lacks SELECT on them." -) -# PostgreSQL's SQLSTATE for a missing privilege. -INSUFFICIENT_PRIVILEGE = "42501" + REFUSED_RESPONSE = { "description": ( "Refused, with the reason in detail: no household function for this " @@ -186,14 +182,12 @@ async def guarded[T](operation: Awaitable[T]) -> T | JSONResponse: except HouseholdWorkersUnavailableError: logger.warning("household_manifest_unavailable") return unavailable(WORKERS_UNAVAILABLE) - except ProgrammingError as error: - if getattr(error.orig, "sqlstate", None) != INSUFFICIENT_PRIVILEGE: - raise + except PublicApiTablesUnreadableError as error: logger.error( # noqa: TRY400 # the log names the missing grant; a traceback adds nothing "household_public_api_tables_unreadable", - extra={"error_type": type(error).__name__}, + extra={"error_type": type(error.__cause__).__name__}, ) - return unavailable(NO_READ_ACCESS) + return unavailable(str(error)) except SQLAlchemyError as error: logger.warning( "household_database_unavailable", diff --git a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py index e4e728615..7bca487d6 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py @@ -181,3 +181,21 @@ def test_a_report_whose_reform_value_the_contract_refuses_leaves_nothing( assert harness.store.report_runs == [] assert harness.store.simulations == {} assert harness.dispatcher.requests == [] + + +def test_a_rerun_on_a_version_lacking_a_parameter_gets_422_and_writes_nothing( + report_client, harness +) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + report_runs_before = list(harness.store.report_runs) + runs_before = dict(harness.store.setups) + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + + response = report_client.post( + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(force=True) + ) + + assert response.status_code == 422 + assert "gov.irs.retired" in response.json()["detail"] + assert harness.store.report_runs == report_runs_before + assert harness.store.setups == runs_before diff --git a/projects/policyengine-simulation-entry/tests/test_household_routes.py b/projects/policyengine-simulation-entry/tests/test_household_routes.py index 44aef6ebd..116ded76d 100644 --- a/projects/policyengine-simulation-entry/tests/test_household_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_household_routes.py @@ -352,3 +352,62 @@ def test_missing_read_access_to_the_public_api_tables_is_named( assert response.status_code == 503 assert "lacks SELECT" in response.json()["detail"] assert response.headers["retry-after"] + + +def test_a_long_refusal_names_at_most_ten_fields(household_client, harness) -> None: + harness.reads.policies[("us", POLICY_ID)] = { + f"gov.parameter_{index:02d}": {"2026-01-01.2100-12-31": {"nested": index}} + for index in range(15) + } + + response = household_client.post( + SUBMIT_PATH, json=submission_body(policy_id=str(POLICY_ID)) + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert "gov.parameter_09" in detail + assert "gov.parameter_10" not in detail + assert "and 5 more" in detail + + +class UndefinedTableError(Exception): + """A psycopg error for a missing table (SQLSTATE 42P01).""" + + sqlstate = "42P01" + + +def test_another_database_programming_error_asks_the_caller_to_retry( + household_client, harness +) -> None: + missing_table = "relation does not exist" + harness.reads.error = ProgrammingError( + "SELECT", {}, UndefinedTableError(missing_table) + ) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert response.json()["detail"] == "The simulation database could not be reached." + + +class MissingPrivilegeStore: + """A store whose reads lack a privilege on sim's own tables.""" + + def read_calculation(self, run_id): + """Fail as a role without SELECT on the simulation schema does.""" + del run_id + statement = "SELECT" + denied = "permission denied for table simulation_runs" + raise ProgrammingError(statement, {}, InsufficientPrivilegeError(denied)) + + +def test_a_missing_privilege_on_sims_own_tables_is_not_named_the_public_apis( + household_client, harness +) -> None: + harness.service.store = MissingPrivilegeStore() + + response = household_client.get(f"{SUBMIT_PATH}/{uuid4()}") + + assert response.status_code == 503 + assert "Public API" not in response.json()["detail"]