diff --git a/.github/scripts/cloud-run-simulation-entry-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh index 84c0726da..d34aa0de5 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) ;; *) @@ -63,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 @@ -72,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)" @@ -131,8 +152,29 @@ 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" ]; 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 + 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..45784c03a 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -58,6 +58,28 @@ 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}") +} + +# 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 + resolved_reference="$(resolve_secret_reference "$2")" + runtime_secrets+=("${variable_name}=${resolved_reference}") +} + required_variables=( PROJECT_ID REGION @@ -77,6 +99,10 @@ 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 + 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 @@ -103,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]+$ ]] \ @@ -115,18 +145,21 @@ 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" -runtime_secrets=( - "OLD_GATEWAY_AUTH_CLIENT_SECRET=$( - resolve_secret_reference "${OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME}" - )" -) +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. +add_runtime_secret SIMULATION_DATABASE_URL "${SIMULATION_DATABASE_URL_SECRET_NAME}" +# 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}" 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 @@ -138,11 +171,7 @@ 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_stage12_runtime_secret STAGE12_DATABASE_URL "${STAGE12_DATABASE_URL_SECRET_NAME}" fi runtime_environment_file="${RUNNER_TEMP}/simulation-entry-runtime-environment.yaml" @@ -167,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/scripts/deploy-cloud-run-simulation-safety-check.sh b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh new file mode 100755 index 000000000..3d3f14307 --- /dev/null +++ b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh @@ -0,0 +1,133 @@ +#!/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), its +# plain telemetry settings, and the Modal environment it starts the economy +# calls left waiting in; nothing else. A revision deployed before the +# safety check existed has no job code, so its schedule is paused instead. +# +# 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() { + printf '%s\n' "$*" >&2 + 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|SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT|OBSERVABILITY_[A-Z_]+|OTEL_[A-Z_]+|POLICYENGINE_OTEL_GOOGLE_AUDIENCE)$' + +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" + +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}" + +# 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 + +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/scripts/migrate-simulation-database.sh b/.github/scripts/migrate-simulation-database.sh new file mode 100755 index 000000000..88bc3d84e --- /dev/null +++ b/.github/scripts/migrate-simulation-database.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 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 + +if [[ -z "${SIMULATION_MIGRATION_DATABASE_URL:-}" ]]; then + printf 'SIMULATION_MIGRATION_DATABASE_URL must name the simulation database\n' >&2 + exit 1 +fi + +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 + +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/scripts/public-api-schema-reference.sh b/.github/scripts/public-api-schema-reference.sh new file mode 100755 index 000000000..2ba20ac74 --- /dev/null +++ b/.github/scripts/public-api-schema-reference.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build the Public API's tables with its own v2 migration chain, for the +# comparison with the persistence tests' copies of them (rule +# sim.persistence.public-api-copies-match). +# +# Usage: public-api-schema-reference.sh +# +# PUBLIC_API_SCHEMA_DATABASE_URL names the empty database to migrate, as +# postgresql://user:password@host:port/database. The Public API's disposable +# migration mode accepts only the local policyengine_v2_alembic_test +# database, with explicit credentials. The script prints the Public API +# commit it migrated and, on GitHub Actions, passes it to the later steps as +# PUBLIC_API_SCHEMA_COMMIT, so a failed comparison names it. +set -euo pipefail + +checkout="${1:?usage: public-api-schema-reference.sh }" +database_url="${PUBLIC_API_SCHEMA_DATABASE_URL:?PUBLIC_API_SCHEMA_DATABASE_URL must name the database to migrate}" + +cd "$checkout" +commit="$(git rev-parse HEAD)" +echo "Public API commit: $commit" +if [ -n "${GITHUB_ENV:-}" ]; then + echo "PUBLIC_API_SCHEMA_COMMIT=$commit" >> "$GITHUB_ENV" +fi +# The Public API installs its lock on Python 3.12, as its own CI does. +export UV_PYTHON=3.12 +uv sync --frozen --extra dev +V2_MIGRATION_DATABASE_URL="postgresql+psycopg://${database_url#*://}" \ + V2_ALEMBIC_DISPOSABLE_TEST=1 \ + uv run --frozen --extra dev alembic -c alembic-v2.ini upgrade head 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/pr.yml b/.github/workflows/pr.yml index 0c27df2f1..28304e79f 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, public-api-schema, lint, pyright, quality-guards, docker-build, test-integration] runs-on: ubuntu-latest permissions: contents: read @@ -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 @@ -75,6 +76,124 @@ 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 + + public-api-schema: + name: Public API schema + # Compares the persistence tests' copies of the Public API's tables with + # the tables the Public API's own v2 migration chain builds, at the Public + # API's default branch, so a change there to a column sim reads fails + # here (rule sim.persistence.public-api-copies-match). The Public API's + # repository is public, so the checkout needs no secret. 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 + permissions: + contents: read + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: simulation_ci + # The Public API's disposable migration mode accepts only this + # database; the server's postgres database holds sim's copies. + POSTGRES_DB: policyengine_v2_alembic_test + 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 + PUBLIC_API_SCHEMA_DATABASE_URL: postgresql://postgres:simulation_ci@127.0.0.1:5432/policyengine_v2_alembic_test + # A missing server or Public API database fails the comparison instead + # of skipping it. + SIMULATION_REQUIRE_DATABASE_TESTS: "1" + SIMULATION_REQUIRE_PUBLIC_API_SCHEMA: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Check out the Public API's default branch + uses: actions/checkout@v6 + with: + repository: PolicyEngine/policyengine-api + path: public-api + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + # 3.12 for the Public API's lock, as its own CI uses; 3.13 for sim's. + python-version: | + 3.12 + 3.13 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Build the Public API's tables from its v2 migration chain + run: .github/scripts/public-api-schema-reference.sh public-api + + - name: Install dependencies + working-directory: libs/policyengine-simulation-persistence + run: uv sync --extra test --frozen + + - name: Compare the test copies with the Public API's tables + working-directory: libs/policyengine-simulation-persistence + run: uv run --frozen pytest tests/ -v -m public_api_schema + lint: name: Lint # Full CI runs only on the lowest unmerged pull request in a stack. @@ -157,6 +276,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/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 9de0a3eff..591530524 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -153,10 +153,40 @@ 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 }} + # 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: 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,11 +267,13 @@ 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 }} 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 @@ -621,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 }} @@ -744,6 +786,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 @@ -952,6 +996,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: @@ -962,6 +1015,62 @@ 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 + 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 + + economy_staging_check: + name: Run a small v2 economy report on staging + # After the staging promotion, the stable URL serves the revision this + # deployment tested: submit a small report there, run it again with + # fresh runs if the submission reused runs an earlier deployment + # finished, and read it back once its comparison is recorded. The v2 + # economy path serves no user yet, so a failure is reported without + # holding back the production deployment. + if: >- + ${{ + !cancelled() && + inputs.deployment_environment == 'staging' && + needs.promote_entrypoint.result == 'success' + }} + needs: [prepare, deploy_entrypoint, promote_entrypoint] + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 30 + environment: ${{ inputs.release_environment }} + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy_entrypoint.outputs.stable_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 }} + SIMULATION_STAGING_ECONOMY_POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + SIMULATION_STAGING_ECONOMY_CHECK: "1" + 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 check dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - name: Run a small economy report on staging and read it back + working-directory: projects/policyengine-simulation-entry + run: uv run --frozen pytest authenticated_tests/test_staging_economy_report.py -v + finalize_routing: name: Finalize the Modal routing publication # Finish the durable journal after every gate, including failed uploads 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/simulation-rollback.yml b/.github/workflows/simulation-rollback.yml index d04d21040..90eeb8abb 100644 --- a/.github/workflows/simulation-rollback.yml +++ b/.github/workflows/simulation-rollback.yml @@ -216,6 +216,19 @@ jobs: .github/scripts/set-cloud-run-simulation-entry-revision.sh 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/.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 12ba40fb5..81bbf82c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,11 +120,15 @@ 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.migrate-before-entrypoint`, `sim.persistence.migration-cycle`, `sim.persistence.no-modal-database-credentials`, `sim.persistence.one-migration-chain`, `sim.persistence.public-api-copies-match` ### 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/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/README.md b/README.md index df4db6273..9a38bb692 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,20 @@ 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, 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 +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 41efb7fd9..646fa9070 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -27,6 +27,7 @@ - Split the routing publisher (src/modal/utils/update_version_registry.py, 1,584 lines) into a src/modal/routing_publisher package of modules for the bundle manifest, the routing state, routing plans and their publication record, the check that only serialized workflows write routing, Modal Dict access, plan publication, the legacy seed and the command-line options, and its 2,728-line test file into a tests/version_registry directory of nine test files with shared fixtures; update_version_registry.py keeps the command line, still runs with python -m src.modal.utils.update_version_registry and re-exports every public name; behaviour and output are unchanged, and the private helpers the new modules share are now public - Removed the UK constituency and local-authority weight-matrix region fallback (_build_uk_weight_replacement_region, _require_certified_uk_weight_matrix and its Stage 12 call); each deployment rebuilds only the app for the current policyengine.py version, now 6.10.0, which routes those regions to the local-area dataset, and apps deployed for older versions are not rebuilt and keep their own code; on a bundle that does not route them, an area region is now an unsupported region - Made UK-wide and nation-level runs on a policyengine.py bundle that routes UK area regions to a local-area release dataset (6.10.0) load only the national dataset and return null for constituency_impact and local_authority_impact, whose results come from a breakdown=local_areas run instead; on a bundle that does not route them, those runs still compute both from the national dataset, and Stage 12 leaves the same breakdowns out of the same reports + - 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/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/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..a19174d2d 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 | 59 | 2 | 0 | 14 | 75 | | metric | 1 | 0 | 0 | 0 | 1 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 56 of 70 rules. +**Blocking:** 62 of 76 rules. ## Gaps @@ -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,11 +52,9 @@ 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` -- `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 +100,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 +112,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 +123,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,11 +132,49 @@ 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.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 | +| `sim.persistence.public-api-copies-match` | The persistence tests' copies of the Public API's tables match the tables the Public API's v2 migration chain builds at its default branch: each copied column's type (an enum by name and values) and nullability, and each copied primary key and unique constraint by name and columns. | mechanical | enforced | yes | pytest `libs/policyengine-simulation-persistence/tests/test_public_api_schema.py` | `pr.yml:public-api-schema` | 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_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"] + 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_public_api_copies_match("sim.persistence.public-api-copies-match (enforced)") + e_pytest_libs_policyengine_simulation_persistence_tests_test_public_api_schema_py["pytest libs/policyengine-simulation-persistence/tests/test_public_api_schema.py"] + j_CI_pr_yml_public_api_schema["CI pr.yml:public-api-schema"] + 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 + r_sim_persistence_one_migration_chain --> e_pytest_libs_policyengine_simulation_persistence_tests_test_ownership_boundaries_py + r_sim_persistence_public_api_copies_match --> e_pytest_libs_policyengine_simulation_persistence_tests_test_public_api_schema_py + e_pytest_libs_policyengine_simulation_persistence_tests_test_public_api_schema_py --> j_CI_pr_yml_public_api_schema +``` + ## Deployment pipeline | 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` | 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 | — | — | @@ -155,8 +187,9 @@ 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_release_gates.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.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_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, 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 @@ -186,6 +219,7 @@ flowchart LR e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py, projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.py"] 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)") e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_traffic_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py"] r_sim_deploy_workflow_shell_safety("sim.deploy.workflow-shell-safety (enforced)") @@ -211,6 +245,7 @@ flowchart LR e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py --> j_CI_pr_yml_test 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_traffic_py e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_traffic_py --> j_CI_pr_yml_test r_sim_deploy_workflow_shell_safety --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_cloud_run_py @@ -310,7 +345,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 +378,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 +412,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-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..4b9a32cf9 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/call_reports.py @@ -0,0 +1,120 @@ +"""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, or finished without holding one (for + # example failed as abandoned before the call's ID was stored). + 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/src/policyengine_simulation_contract/economy_runs.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/economy_runs.py new file mode 100644 index 000000000..5d157d2ab --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/economy_runs.py @@ -0,0 +1,243 @@ +"""The Modal calls the Simulation Entrypoint starts for v2 economy runs. + +Each economy simulation run has its own Modal call, and each economy report +run's comparison step another (``simulation_runs`` and ``report_runs`` in the +persistence library): + +- ``EconomySimulationCall`` asks a country's ``ECONOMY_SIMULATION_FUNCTIONS`` + function to calculate one simulation run and store its output; the call + returns an ``EconomySimulationOutput`` naming the stored files. +- ``EconomyComparisonCall`` asks ``ECONOMY_COMPARISON_FUNCTION`` to compare two + stored simulation outputs; the call returns a ``SingleYearMacroOutput``. + +The entrypoint reads the region and dataset a run uses from the Public API's +region catalog and sends them as a ``CatalogPopulation``: the dataset's name, +and the household column and value that select the region's households. The +worker applies them itself, without policyengine.py's region registry. + +A simulation's output columns do not depend on the report it is compared in, +so a run can be reused by any report, except for the labour supply response +columns: they are calculated only when ``labor_supply_response`` is set, +because calculating them in the UK runs the simulation again. The entrypoint +sets it when a policy of any report run comparing the run changes an +elasticity (``policy_affects_labor_supply``). +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Annotated, Literal, Self +from uuid import UUID + +from pydantic import Field, JsonValue, field_validator, model_validator + +from policyengine_simulation_contract.stage12_execution import ( + ArtifactReference, + BundleProvenance, + ContractText, + CountryId, + RowIdentity, + Sha256Digest, + StorageUri, + StrictContractModel, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +ECONOMY_SIMULATION_FUNCTIONS: Mapping[str, str] = { + "us": "run_economy_simulation_us", + "uk": "run_economy_simulation_uk", +} +ECONOMY_COMPARISON_FUNCTION = "compare_economy_simulations" + +# The parameters whose change makes policyengine.py calculate a labour supply +# response, by country: a parameter at or below one of these paths. They are +# policyengine.py's own (``policyengine.outputs.labor_supply_response``); the +# executor's tests check that the two agree. +LABOR_SUPPLY_RESPONSE_PARAMETER_PREFIXES: Mapping[str, tuple[str, ...]] = { + "us": ( + "gov.simulation.labor_supply_responses.elasticities.income", + "gov.simulation.labor_supply_responses.elasticities.substitution", + ), + "uk": ( + "gov.simulation.labour_supply_responses.income_elasticity", + "gov.simulation.labour_supply_responses.substitution_elasticity", + "gov.simulation.labor_supply_responses.income_elasticity", + "gov.simulation.labor_supply_responses.substitution_elasticity", + ), +} + +# A parameter's values by period: a date (``2026-01-01``) or an inclusive +# ``start.stop`` pair of dates, as policyengine.py's policy dictionaries key +# them. +type PolicyChanges = dict[str, dict[str, JsonValue]] +FilterValue = Annotated[str, Field(min_length=1, max_length=4096)] +# The region catalog's code for each country as a whole. +NATIONAL_REGION_CODES = frozenset({"us", "uk"}) +YEAR_RANGE = (1900, 2200) +_INTEGER = re.compile(r"-?\d+") + + +def policy_affects_labor_supply(country: str, policy: Mapping[str, object]) -> bool: + """Return whether ``policy`` changes a labour supply elasticity.""" + prefixes = LABOR_SUPPLY_RESPONSE_PARAMETER_PREFIXES.get(country, ()) + return any( + name == prefix or name.startswith(f"{prefix}.") + for name in policy + for prefix in prefixes + ) + + +def _household_scalar(text: str) -> str | int: + """Return an integer written as one (US FIPS numbers and GEOIDs), else text.""" + return int(text) if _INTEGER.fullmatch(text) else text + + +class CatalogRowFilter(StrictContractModel): + """The household column and value that select a region's households. + + ``value`` is the text the region catalog stores: one value, or a JSON list + of text values when a region covers several (England, the nine English + regions). + """ + + field: ContractText + value: FilterValue + + @field_validator("value") + @classmethod + def _decodable(cls, value: str) -> str: + if value.startswith("["): + try: + items = json.loads(value) + except ValueError: + msg = "a list filter value must be a JSON list of text values" + raise ValueError(msg) from None + if ( + not isinstance(items, list) + or not items + or not all(isinstance(item, str) and item for item in items) + ): + msg = "a list filter value must be a JSON list of text values" + raise ValueError(msg) + return value + + def household_value(self) -> str | int | list[str | int]: + """Return the value or values a household's column is compared with. + + Each value that is written as an integer is compared as one, so a + stored ``"6"`` selects the households whose ``state_fips`` is 6. + """ + if self.value.startswith("["): + return [_household_scalar(item) for item in json.loads(self.value)] + return _household_scalar(self.value) + + +class CatalogPopulation(StrictContractModel): + """The region and dataset one economy run uses, as its catalog version has them. + + ``dataset_name`` names a dataset of the policyengine.py release. A + region's ``row_filter`` selects its households in that dataset; a + national region has none. ``state_code`` names the US state a state or + congressional-district region lies in. A US national run also carries + ``state_filters``, each state region's filter by its code, so it can + calculate its states in separate segments and add them up. + """ + + region_code: ContractText + dataset_name: ContractText + row_filter: CatalogRowFilter | None = None + state_code: Annotated[str, Field(pattern=r"^[A-Z]{2}$")] | None = None + state_filters: dict[str, CatalogRowFilter] | None = None + + @model_validator(mode="after") + def _filtered_unless_national(self) -> Self: + """Refuse a region below the country that selects no households. + + Without a filter the whole country's dataset would be calculated and + stored as the region's result. + """ + if self.row_filter is None and self.region_code not in NATIONAL_REGION_CODES: + msg = f"the region {self.region_code} has no household filter" + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _state_filters_for_a_national_region(self) -> Self: + if self.state_filters is None: + return self + if self.row_filter is not None: + msg = "only a national region, which has no filter, has state filters" + raise ValueError(msg) + if not self.state_filters or any( + not code.startswith("state/") for code in self.state_filters + ): + msg = "state filters must be keyed by state region codes" + raise ValueError(msg) + return self + + +class EconomySimulationCall(StrictContractModel): + """Calculate one economy simulation run, and store its output. + + ``policy`` holds the parameter values the run's policy, and its dynamic if + any, set, by parameter name as the run's catalog version names them; + current law has none. ``policyengine_version`` must be the version the + called application installs. ``artifact_environment`` names the + deployment's environment in the stored files' paths. + """ + + schema_version: Literal[1] = 1 + simulation_run_id: UUID + country: CountryId + year: Annotated[int, Field(ge=YEAR_RANGE[0], le=YEAR_RANGE[1])] + policyengine_version: ContractText + policy: PolicyChanges + population: CatalogPopulation + labor_supply_response: bool = False + artifact_environment: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,31}$")] + + +class EconomySimulationOutput(StrictContractModel): + """What an economy simulation call stored: its input and its output. + + ``output`` is the Parquet file of the simulation's entity tables, whose + columns follow the output plan with digest ``output_plan_sha256``. + """ + + schema_version: Literal[1] = 1 + simulation_run_id: UUID + input: ArtifactReference + output: ArtifactReference + output_plan_sha256: Sha256Digest + row_identity: RowIdentity + bundle: BundleProvenance + + +class EconomyComparisonSide(StrictContractModel): + """One simulation run a comparison reads: its policy and stored output.""" + + simulation_run_id: UUID + policy: PolicyChanges + output_uri: StorageUri + output_sha256: Sha256Digest + + +class EconomyComparisonCall(StrictContractModel): + """Compare two stored economy simulation outputs of one report run. + + Both simulation runs calculated the same region and dataset for the same + catalog version and year; the call returns a ``SingleYearMacroOutput``. + """ + + schema_version: Literal[1] = 1 + report_run_id: UUID + country: CountryId + year: Annotated[int, Field(ge=YEAR_RANGE[0], le=YEAR_RANGE[1])] + policyengine_version: ContractText + region_code: ContractText + dataset_name: ContractText + baseline: EconomyComparisonSide + reform: EconomyComparisonSide diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/report_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/report_execution.py new file mode 100644 index 000000000..a25c4c29a --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/report_execution.py @@ -0,0 +1,226 @@ +"""The typed outcome a report coordinator returns to its caller. + +A report coordinator dispatched with ``result_delivery`` set to +``ReportResultDelivery.RETURN_TO_CALLER`` reads and writes no Stage 12 rows. +When the report succeeds it returns a ``ReportExecutionResult``, which carries +the report's aggregate inline, references to the baseline and reform simulation +artifacts, and the software, data and Modal deployment that produced them. +When the report fails it raises ``ReportExecutionError``, which carries the +redacted error code and summary that a Stage 12 report row would record for +the same failure. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from hashlib import sha256 +from typing import TYPE_CHECKING, Annotated, Any, Literal +from uuid import UUID + +from pydantic import Field, field_validator, model_validator + +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.stage12_execution import ( + BundleProvenance, + ContractText, + ErrorCode, + ErrorSummary, + ReportAggregate, + Sha256Digest, + SimulationArtifactDescriptor, + SimulationRole, + Stage12OutputPlan, + StrictContractModel, + stage12_output_plan_sha256, +) + +if TYPE_CHECKING: + from collections.abc import Callable + +REPORT_EXECUTION_ERROR_MESSAGE = "Report execution failed" + + +def report_aggregate_sha256(aggregate: SingleYearMacroOutput) -> str: + """Return the SHA-256 of the aggregate's canonical JSON encoding. + + The encoding is compact, key-sorted UTF-8 JSON of the aggregate's JSON-mode + dump, so an aggregate has the same digest before and after a JSON round + trip. Raises ``ValueError`` when the aggregate contains NaN or infinity. + """ + try: + payload = json.dumps( + aggregate.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + except ValueError: + msg = "aggregate contains a non-finite number" + raise ValueError(msg) from None + return sha256(payload).hexdigest() + + +class ReportExecutionProvenance(StrictContractModel): + """The software, data and Modal deployment that produced one report. + + ``bundle`` names the PolicyEngine.py version, the country model package, + the dataset and the bundle manifest they came from. The other fields name + the Modal environment, application and simulation function that ran, the + digest of the version manifest they were resolved from, and the Modal + function call ID of the report coordinator. + """ + + bundle: BundleProvenance + worker_version: ContractText + modal_environment: ContractText + modal_application: ContractText + simulation_callable: ContractText + version_manifest_sha256: Sha256Digest + coordinator_invocation_id: ContractText + + +class ReportExecutionResult(StrictContractModel): + """A completed report, as its coordinator returns it to the caller. + + ``aggregate`` is the report's single-year macro result: its totals, inline. + ``aggregate_sha256`` is the digest ``report_aggregate_sha256`` gives for it. + ``baseline`` and ``reform`` reference the two simulation artifacts the + aggregate was computed from, and ``output_plan`` declares the columns they + hold. Both artifacts must belong to this report, carry the baseline and + reform roles, follow the output plan, cover the same rows and use the + bundle named in ``provenance``. ``requested_aggregates`` must be the + aggregates the output plan was built for, in the same order. + """ + + contract_version: Literal[1] = 1 + evaluation_id: UUID + requested_aggregates: Annotated[tuple[ReportAggregate, ...], Field(min_length=1)] + aggregate_schema_version: Literal[1] = 1 + aggregate: SingleYearMacroOutput + aggregate_sha256: Sha256Digest + output_plan: Stage12OutputPlan + baseline: SimulationArtifactDescriptor + reform: SimulationArtifactDescriptor + provenance: ReportExecutionProvenance + completed_at: datetime + + @field_validator("requested_aggregates") + @classmethod + def require_unique_aggregates( + cls, + value: tuple[ReportAggregate, ...], + ) -> tuple[ReportAggregate, ...]: + """Reject repeated aggregate names.""" + if len(value) != len(set(value)): + msg = "requested aggregates must be unique" + raise ValueError(msg) + return value + + @field_validator("completed_at") + @classmethod + def require_aware_completion_time(cls, value: datetime) -> datetime: + """Reject a completion time without a timezone.""" + if value.tzinfo is None: + msg = "completed_at must include a timezone" + raise ValueError(msg) + return value + + @model_validator(mode="after") + def require_aggregates_of_the_output_plan(self) -> ReportExecutionResult: + """Require the requested aggregates the output plan was built for. + + The coordinator builds the output plan from the report's requested + aggregates, so a result naming others contradicts its own plan. + """ + if self.requested_aggregates != self.output_plan.requirements.aggregates: + msg = "requested aggregates must match the output plan's aggregates" + raise ValueError(msg) + return self + + @model_validator(mode="after") + def require_artifacts_of_this_report(self) -> ReportExecutionResult: + """Require a baseline and a reform artifact that form this report.""" + if self.baseline.role is not SimulationRole.BASELINE: + msg = "the baseline artifact must use the baseline role" + raise ValueError(msg) + if self.reform.role is not SimulationRole.REFORM: + msg = "the reform artifact must use the reform role" + raise ValueError(msg) + for descriptor in (self.baseline, self.reform): + self._require_artifact_of_this_report(descriptor) + if self.baseline.simulation_execution_id == self.reform.simulation_execution_id: + msg = ( + "baseline and reform artifacts must have different simulation " + "identifiers" + ) + raise ValueError(msg) + if self.baseline.row_identity != self.reform.row_identity: + msg = "baseline and reform artifacts must cover the same rows" + raise ValueError(msg) + return self + + def _require_artifact_of_this_report( + self, + descriptor: SimulationArtifactDescriptor, + ) -> None: + """Require one artifact to belong to this report, bundle and output plan.""" + if descriptor.evaluation_id != self.evaluation_id: + msg = "simulation artifacts must belong to this report" + raise ValueError(msg) + if descriptor.bundle != self.provenance.bundle: + msg = "simulation artifacts must use the report's bundle" + raise ValueError(msg) + if descriptor.output_plan_sha256 != stage12_output_plan_sha256( + self.output_plan + ): + msg = "simulation artifacts must follow the report's output plan" + raise ValueError(msg) + + @model_validator(mode="after") + def require_the_aggregate_digest(self) -> ReportExecutionResult: + """Require ``aggregate_sha256`` to be the digest of ``aggregate``.""" + if report_aggregate_sha256(self.aggregate) != self.aggregate_sha256: + msg = "aggregate_sha256 does not match the aggregate" + raise ValueError(msg) + return self + + +class ReportExecutionFailure(StrictContractModel): + """A failed report or simulation: its redacted error code and summary. + + The values are the ones a Stage 12 row records for the same failure, which + are safe to store and to show to the caller. + """ + + contract_version: Literal[1] = 1 + evaluation_id: UUID + error_code: ErrorCode + error_summary: ErrorSummary + + +class ReportExecutionError(RuntimeError): + """Raised by a coordinator or simulation whose result goes to its caller. + + The message is fixed; the failure is in ``failure``. The error pickles + with its failure as plain JSON values, so Modal's ``FunctionCall.get`` + raises it in the caller as this type with the same failure, provided the + caller can import this module, whatever pydantic version each side runs. + """ + + def __init__(self, failure: ReportExecutionFailure) -> None: + """Carry ``failure`` under the fixed message.""" + self.failure = failure + super().__init__(REPORT_EXECUTION_ERROR_MESSAGE) + + def __reduce__( + self, + ) -> tuple[Callable[[dict[str, Any]], ReportExecutionError], tuple[dict[str, Any]]]: + """Rebuild the error from its failure's JSON values when it is unpickled.""" + return (_report_execution_error, (self.failure.model_dump(mode="json"),)) + + +def _report_execution_error(failure: dict[str, Any]) -> ReportExecutionError: + """Rebuild a pickled ``ReportExecutionError`` from its failure's JSON values.""" + return ReportExecutionError(ReportExecutionFailure.model_validate(failure)) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/__init__.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/__init__.py index d1a1f477f..bee04e69f 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/__init__.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/__init__.py @@ -38,6 +38,7 @@ ComparisonSimulationRecord, ErrorCode, ErrorSummary, + ReportResultDelivery, ResultComparisonStatus, Stage12InvocationContext, ) @@ -95,6 +96,7 @@ "ReportAggregate", "ReportExecutionInput", "ReportOutputRequirements", + "ReportResultDelivery", "ResultComparisonArtifactPayload", "ResultComparisonStatus", "ResultDifference", diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/comparison_runs.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/comparison_runs.py index ea49b5e2d..fc467eb21 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/comparison_runs.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_execution/comparison_runs.py @@ -23,13 +23,29 @@ ) +class ReportResultDelivery(StrEnum): + """How a report coordinator and its simulations deliver their outcome. + + ``STAGE12_DATABASE``, the default, records the report's and each + simulation's state in the Stage 12 tables, and the coordinator returns an + aggregate artifact descriptor. ``RETURN_TO_CALLER`` reads and writes no + Stage 12 rows: the coordinator returns a ``ReportExecutionResult`` (see + ``report_execution``) or raises ``ReportExecutionError``. + """ + + STAGE12_DATABASE = "stage12_database" + RETURN_TO_CALLER = "return_to_caller" + + class Stage12InvocationContext(StrictContractModel): """Deployment and retention context for one comparison-run invocation. Records the originating request, the environments, the worker version and callable, the manifest digests and the artifact prefix. Automatic runs also name the production function call they are compared with. Artifacts are kept - for at most 30 days after ``created_at``. + for at most 30 days after ``created_at``. ``result_delivery`` selects how the + coordinator and its simulations deliver their outcome; a report returned to + its caller cannot be compared with a production job. """ contract_version: Literal[1] = 1 @@ -51,6 +67,9 @@ class Stage12InvocationContext(StrictContractModel): production_function_call_id: ContractText | None = None created_at: datetime retention_expires_at: datetime + # Callers that leave it out, such as the Simulation Entrypoint today, keep + # the Stage 12 database behavior. + result_delivery: ReportResultDelivery = ReportResultDelivery.STAGE12_DATABASE @model_validator(mode="after") def validate_retention(self) -> Stage12InvocationContext: @@ -58,6 +77,23 @@ def validate_retention(self) -> Stage12InvocationContext: _validate_retention(self.created_at, self.retention_expires_at) return self + @model_validator(mode="after") + def validate_result_delivery(self) -> Stage12InvocationContext: + """Reject a production comparison for a report returned to its caller. + + The comparison is recorded on the Stage 12 report row, which a + returned report never writes. + """ + if ( + self.result_delivery is ReportResultDelivery.RETURN_TO_CALLER + and self.production_function_call_id is not None + ): + msg = ( + "a report returned to its caller cannot request a production comparison" + ) + raise ValueError(msg) + return self + ErrorCode = Annotated[str, Field(min_length=1, max_length=64)] ErrorSummary = Annotated[str, Field(min_length=1, max_length=512)] 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 diff --git a/libs/policyengine-simulation-contract/tests/test_economy_runs.py b/libs/policyengine-simulation-contract/tests/test_economy_runs.py new file mode 100644 index 000000000..8495ec556 --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_economy_runs.py @@ -0,0 +1,198 @@ +"""The calls the Simulation Entrypoint starts for v2 economy runs.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_contract.economy_runs import ( + ECONOMY_COMPARISON_FUNCTION, + ECONOMY_SIMULATION_FUNCTIONS, + CatalogPopulation, + CatalogRowFilter, + EconomyComparisonCall, + EconomyComparisonSide, + EconomySimulationCall, + policy_affects_labor_supply, +) +from pydantic import ValidationError + +OUTPUT_SHA256 = "a" * 64 + + +def _state_filter(fips: str) -> CatalogRowFilter: + return CatalogRowFilter(field="state_fips", value=fips) + + +def _call(**changes: object) -> EconomySimulationCall: + values: dict[str, object] = { + "simulation_run_id": uuid4(), + "country": "us", + "year": 2026, + "policyengine_version": "6.7.5", + "policy": {"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01": 3000}}, + "population": CatalogPopulation( + region_code="state/ca", + dataset_name="populace_us_2024_acs_local", + row_filter=_state_filter("6"), + state_code="CA", + ), + "artifact_environment": "staging", + } + values.update(changes) + return EconomySimulationCall.model_validate(values) + + +@pytest.mark.parametrize( + ("stored", "household_value"), + [ + ("6", 6), + ("601", 601), + ("WALES", "WALES"), + ("E14001063", "E14001063"), + ('["NORTH_EAST","LONDON"]', ["NORTH_EAST", "LONDON"]), + ('["1","2"]', [1, 2]), + ], +) +def test_a_catalog_filter_value_becomes_the_value_households_are_compared_with( + stored: str, household_value: object +) -> None: + row_filter = CatalogRowFilter(field="region", value=stored) + + assert row_filter.household_value() == household_value + + +@pytest.mark.parametrize("stored", ["[]", '["NORTH_EAST", 3]', "[1", '[""]']) +def test_a_malformed_catalog_filter_value_is_refused(stored: str) -> None: + with pytest.raises(ValidationError): + CatalogRowFilter(field="region", value=stored) + + +def test_a_call_round_trips_as_json() -> None: + call = _call() + + assert EconomySimulationCall.model_validate_json(call.model_dump_json()) == call + + +def test_a_national_population_names_each_state_filter_and_none_of_its_own() -> None: + population = CatalogPopulation( + region_code="us", + dataset_name="populace_us_2024", + state_filters={"state/ca": _state_filter("6"), "state/ny": _state_filter("36")}, + ) + + assert population.row_filter is None + assert population.state_filters is not None + assert population.state_filters["state/ny"].household_value() == 36 + + +def test_a_population_cannot_have_a_filter_and_state_filters() -> None: + with pytest.raises(ValidationError, match="national"): + CatalogPopulation( + region_code="state/ca", + dataset_name="populace_us_2024", + row_filter=_state_filter("6"), + state_filters={"state/ca": _state_filter("6")}, + ) + + +def test_state_filters_are_keyed_by_state_region_codes() -> None: + with pytest.raises(ValidationError, match="state"): + CatalogPopulation( + region_code="us", + dataset_name="populace_us_2024", + state_filters={"congressional_district/CA-01": _state_filter("601")}, + ) + + +def test_a_state_code_is_two_capital_letters() -> None: + with pytest.raises(ValidationError): + CatalogPopulation( + region_code="state/ca", + dataset_name="populace_us_2024", + row_filter=_state_filter("6"), + state_code="california", + ) + + +def test_a_call_refuses_a_policy_value_that_is_not_keyed_by_period() -> None: + with pytest.raises(ValidationError): + _call(policy={"gov.irs.credits.ctc.amount.base[0].amount": 3000}) + + +def test_a_call_refuses_unknown_fields() -> None: + with pytest.raises(ValidationError): + EconomySimulationCall.model_validate( + {**_call().model_dump(mode="json"), "region": "state/ca"} + ) + + +def test_each_country_has_its_simulation_function() -> None: + assert ECONOMY_SIMULATION_FUNCTIONS == { + "us": "run_economy_simulation_us", + "uk": "run_economy_simulation_uk", + } + assert ECONOMY_COMPARISON_FUNCTION == "compare_economy_simulations" + + +@pytest.mark.parametrize( + ("country", "parameter", "affects"), + [ + ("us", "gov.simulation.labor_supply_responses.elasticities.income", True), + ( + "us", + "gov.simulation.labor_supply_responses.elasticities.substitution.all", + True, + ), + ("us", "gov.simulation.labor_supply_responses.elasticities.incomes", False), + ("us", "gov.irs.credits.ctc.amount.base[0].amount", False), + ("uk", "gov.simulation.labour_supply_responses.income_elasticity", True), + ("uk", "gov.simulation.labor_supply_responses.substitution_elasticity", True), + ("uk", "gov.simulation.labor_supply_responses.elasticities.income", False), + ], +) +def test_a_policy_affects_labour_supply_when_it_changes_an_elasticity( + country: str, parameter: str, *, affects: bool +) -> None: + policy = {parameter: {"2026-01-01": 0.1}} + + assert policy_affects_labor_supply(country, policy) is affects + + +def test_a_comparison_names_both_sides_outputs() -> None: + side = EconomyComparisonSide( + simulation_run_id=uuid4(), + policy={}, + output_uri="gs://bucket/economy-simulation-runs/staging/run.parquet", + output_sha256=OUTPUT_SHA256, + ) + + call = EconomyComparisonCall( + report_run_id=uuid4(), + country="uk", + year=2026, + policyengine_version="6.7.5", + region_code="country/wales", + dataset_name="enhanced_frs_2023_24", + baseline=side, + reform=side, + ) + + assert EconomyComparisonCall.model_validate_json(call.model_dump_json()) == call + + +def test_a_comparison_side_names_its_output_by_a_cloud_storage_uri() -> None: + with pytest.raises(ValidationError): + EconomyComparisonSide( + simulation_run_id=uuid4(), + policy={}, + output_uri="https://example.org/run.parquet", + output_sha256=OUTPUT_SHA256, + ) + + +def test_only_a_national_region_runs_without_a_household_filter() -> None: + CatalogPopulation(region_code="uk", dataset_name="enhanced_frs_2023_24") + + with pytest.raises(ValidationError, match="household filter"): + CatalogPopulation(region_code="place/CA-44000", dataset_name="populace_us_2024") diff --git a/libs/policyengine-simulation-contract/tests/test_report_execution.py b/libs/policyengine-simulation-contract/tests/test_report_execution.py new file mode 100644 index 000000000..9e881f5ea --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_report_execution.py @@ -0,0 +1,489 @@ +"""Tests for the typed outcome a report coordinator returns to its caller.""" + +from __future__ import annotations + +import json +import pickle +from datetime import UTC, datetime +from hashlib import sha256 +from typing import Any +from uuid import UUID + +import pytest +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.report_execution import ( + ReportExecutionError, + ReportExecutionFailure, + ReportExecutionProvenance, + ReportExecutionResult, + report_aggregate_sha256, +) +from policyengine_simulation_contract.spm import ( + SPMRuntimeVersions, + SPMSelection, + build_spm_comparison_provenance, + build_spm_provenance, +) +from policyengine_simulation_contract.stage12_execution import ( + ArtifactMediaType, + ArtifactReference, + BundleProvenance, + DatasetProvenance, + EntityOutputPlan, + ReportAggregate, + ReportOutputRequirements, + RowIdentity, + SimulationArtifactDescriptor, + SimulationRole, + Stage12OutputPlan, + stage12_output_plan_sha256, +) +from pydantic import ValidationError + +COMPLETED_AT = datetime(2026, 10, 22, tzinfo=UTC) +EVALUATION_ID = UUID("00000000-0000-0000-0000-000000000001") +BASELINE_ID = UUID("00000000-0000-0000-0000-000000000002") +REFORM_ID = UUID("00000000-0000-0000-0000-000000000003") + + +def _bundle() -> BundleProvenance: + return BundleProvenance( + policyengine_version="6.7.5", + country_package_name="policyengine-us", + country_package_version="2.2.1", + dataset=DatasetProvenance( + identity="populace_us_2024", + uri="hf://policyengine/data/populace_us_2024.h5@revision", + artifact_revision="revision", + data_package_name="populace-data", + data_package_version="0.1.0", + ), + bundle_manifest_sha256="b" * 64, + ) + + +def _output_plan() -> Stage12OutputPlan: + return Stage12OutputPlan( + country="us", + requirements=ReportOutputRequirements( + aggregates=tuple(ReportAggregate), + include_cliff_impacts=False, + labor_supply_response_active=False, + ), + entities=( + EntityOutputPlan( + entity="household", + materialized_variables=("household_id", "household_net_income"), + ), + ), + ) + + +def _descriptor(role: SimulationRole) -> SimulationArtifactDescriptor: + is_baseline = role is SimulationRole.BASELINE + return SimulationArtifactDescriptor( + evaluation_id=EVALUATION_ID, + simulation_execution_id=BASELINE_ID if is_baseline else REFORM_ID, + role=role, + artifact=ArtifactReference( + uri=f"gs://private/stage-12-runs/staging/{role.value}.parquet", + media_type=ArtifactMediaType.PARQUET, + content_sha256=("1" if is_baseline else "2") * 64, + size_bytes=2048, + ), + output_plan_sha256=stage12_output_plan_sha256(_output_plan()), + row_identity=RowIdentity( + identifier_columns=("household.household_id",), + row_count=1, + identity_sha256="c" * 64, + ), + bundle=_bundle(), + ) + + +def _baseline_and_reform(baseline: float, reform: float) -> dict[str, float]: + return {"baseline": baseline, "reform": reform} + + +def _poverty_by_age() -> dict[str, dict[str, float]]: + return { + group: _baseline_and_reform(0.1, 0.09) + for group in ("child", "adult", "senior", "all") + } + + +def _aggregate() -> dict[str, Any]: + """Return a complete single-year macro result with the US-only sections set.""" + return { + "spm_provenance": None, + "model_version": "2.2.1", + "data_version": "revision", + "budget": { + "tax_revenue_impact": 100.0, + "state_tax_revenue_impact": 20.0, + "benefit_spending_impact": 30.0, + "budgetary_impact": 70.0, + "households": 2.0, + "baseline_net_income": 1000.0, + }, + "detailed_budget": { + "income_tax": {"baseline": 100.0, "reform": 125.0, "difference": 25.0} + }, + "decile": { + "average": {"1": 10.0, "10": 20.0, "2": 15.0}, + "relative": {"1": 0.01, "10": 0.02, "2": 0.015}, + }, + "inequality": { + "gini": _baseline_and_reform(0.4, 0.39), + "top_10_pct_share": _baseline_and_reform(0.3, 0.29), + "top_1_pct_share": _baseline_and_reform(0.1, 0.09), + }, + "poverty": {"poverty": _poverty_by_age(), "deep_poverty": _poverty_by_age()}, + "poverty_by_gender": { + "poverty": { + "male": _baseline_and_reform(0.08, 0.07), + "female": _baseline_and_reform(0.09, 0.08), + }, + "deep_poverty": { + "male": _baseline_and_reform(0.02, 0.02), + "female": _baseline_and_reform(0.03, 0.02), + }, + }, + "poverty_by_race": { + "poverty": { + race: _baseline_and_reform(0.06, 0.05) + for race in ("white", "black", "hispanic", "other") + } + }, + "intra_decile": { + "deciles": {"Gain less than 5%": [0.4, 0.2], "No change": [0.6, 0.8]}, + "all": {"Gain less than 5%": 0.3, "No change": 0.7}, + }, + "wealth_decile": None, + "intra_wealth_decile": None, + "labor_supply_response": None, + "constituency_impact": None, + "local_authority_impact": None, + "congressional_district_impact": { + "districts": [ + { + "district": "AL-01", + "average_household_income_change": 10.0, + "relative_household_income_change": 0.01, + "winner_percentage": 0.6, + "loser_percentage": 0.3, + "no_change_percentage": 0.1, + "population": 1000.0, + } + ] + }, + "cliff_impact": None, + } + + +def _spm_comparison() -> dict[str, Any]: + selection = SPMSelection( + forecast_content_sha256="a" * 64, + scenario="official", + geography_kind="national", + geography_id=None, + county_vintage="2020", + as_of=None, + ) + receipt = build_spm_provenance( + forecast_id="forecast-2026", + forecast_sha256="a" * 64, + selection=selection, + years=("2026",), + runtime_versions=SPMRuntimeVersions.model_validate( + { + "policyengine": "6.7.5", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + } + ), + ) + return build_spm_comparison_provenance( + baseline_receipts=(receipt,), + reform_receipts=(receipt,), + ).model_dump(mode="json", by_alias=True) + + +def _provenance() -> ReportExecutionProvenance: + return ReportExecutionProvenance( + bundle=_bundle(), + worker_version="6.7.5", + modal_environment="staging", + modal_application="policyengine-simulation-v2-py6-7-5", + simulation_callable="run_single_simulation_us", + version_manifest_sha256="d" * 64, + coordinator_invocation_id="fc-coordinator", + ) + + +def _result_payload(**overrides: Any) -> dict[str, Any]: + aggregate = overrides.pop("aggregate", _aggregate()) + payload: dict[str, Any] = { + "evaluation_id": str(EVALUATION_ID), + "requested_aggregates": [ + aggregate_name.value for aggregate_name in ReportAggregate + ], + "aggregate": aggregate, + "aggregate_sha256": report_aggregate_sha256( + SingleYearMacroOutput.model_validate(aggregate) + ), + "output_plan": _output_plan().model_dump(mode="json"), + "baseline": _descriptor(SimulationRole.BASELINE).model_dump(mode="json"), + "reform": _descriptor(SimulationRole.REFORM).model_dump(mode="json"), + "provenance": _provenance().model_dump(mode="json"), + "completed_at": COMPLETED_AT.isoformat(), + } + payload.update(overrides) + return payload + + +def _failure() -> ReportExecutionFailure: + return ReportExecutionFailure( + evaluation_id=EVALUATION_ID, + error_code="simulation_execution_failed", + error_summary="ValueError in dataset_load (baseline): bad input", + ) + + +def test_result_round_trips_through_json() -> None: + result = ReportExecutionResult.model_validate(_result_payload()) + + restored = ReportExecutionResult.model_validate( + json.loads(json.dumps(result.model_dump(mode="json"))) + ) + + assert restored == result + assert restored.aggregate.budget.budgetary_impact == 70.0 + assert restored.baseline.role is SimulationRole.BASELINE + assert restored.provenance.bundle == _bundle() + + +def test_result_carries_the_totals_inline_as_a_single_year_macro_output() -> None: + result = ReportExecutionResult.model_validate(_result_payload()) + + assert isinstance(result.aggregate, SingleYearMacroOutput) + assert result.aggregate.model_dump(mode="json") == _aggregate() + + +@pytest.mark.parametrize( + "change", + [ + lambda aggregate: aggregate.pop("budget"), + lambda aggregate: aggregate.update({"unexpected_section": {}}), + lambda aggregate: aggregate["budget"].update({"budgetary_impact": "large"}), + ], + ids=["missing-section", "unknown-section", "non-numeric-value"], +) +def test_result_rejects_an_aggregate_outside_the_macro_output_schema(change) -> None: + aggregate = _aggregate() + change(aggregate) + payload = _result_payload() + payload["aggregate"] = aggregate + + with pytest.raises(ValidationError): + ReportExecutionResult.model_validate(payload) + + +def test_result_rejects_an_aggregate_digest_that_does_not_match() -> None: + with pytest.raises(ValidationError, match="aggregate_sha256"): + ReportExecutionResult.model_validate(_result_payload(aggregate_sha256="e" * 64)) + + +def test_result_rejects_a_non_finite_aggregate_value() -> None: + aggregate = _aggregate() + aggregate["budget"]["budgetary_impact"] = float("nan") + payload = _result_payload() + payload["aggregate"] = aggregate + + with pytest.raises(ValidationError, match="non-finite"): + ReportExecutionResult.model_validate(payload) + + +def test_aggregate_digest_is_the_digest_of_its_canonical_json() -> None: + aggregate = SingleYearMacroOutput.model_validate(_aggregate()) + reordered = SingleYearMacroOutput.model_validate( + dict(reversed(list(_aggregate().items()))) + ) + expected = sha256( + json.dumps( + _aggregate(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + + assert report_aggregate_sha256(aggregate) == expected + assert report_aggregate_sha256(reordered) == expected + + +def test_aggregate_digest_survives_a_json_round_trip_with_spm_provenance() -> None: + aggregate_payload = {**_aggregate(), "spm_provenance": _spm_comparison()} + aggregate = SingleYearMacroOutput.model_validate(aggregate_payload) + restored = SingleYearMacroOutput.model_validate( + json.loads(json.dumps(aggregate.model_dump(mode="json"))) + ) + + assert aggregate.model_dump(mode="json") == aggregate_payload + assert report_aggregate_sha256(restored) == report_aggregate_sha256(aggregate) + ReportExecutionResult.model_validate(_result_payload(aggregate=aggregate_payload)) + + +@pytest.mark.parametrize( + ("field", "replacement", "message"), + [ + ( + "baseline", + lambda: _descriptor(SimulationRole.REFORM).model_copy( + update={"simulation_execution_id": BASELINE_ID} + ), + "baseline artifact must use the baseline role", + ), + ( + "reform", + lambda: _descriptor(SimulationRole.BASELINE).model_copy( + update={"simulation_execution_id": REFORM_ID} + ), + "reform artifact must use the reform role", + ), + ( + "reform", + lambda: _descriptor(SimulationRole.REFORM).model_copy( + update={"evaluation_id": UUID(int=99)} + ), + "must belong to this report", + ), + ( + "reform", + lambda: _descriptor(SimulationRole.REFORM).model_copy( + update={"simulation_execution_id": BASELINE_ID} + ), + "different simulation identifiers", + ), + ( + "reform", + lambda: _descriptor(SimulationRole.REFORM).model_copy( + update={ + "bundle": _bundle().model_copy( + update={"country_package_version": "2.2.2"} + ) + } + ), + "must use the report's bundle", + ), + ( + "baseline", + lambda: _descriptor(SimulationRole.BASELINE).model_copy( + update={"output_plan_sha256": "f" * 64} + ), + "must follow the report's output plan", + ), + ( + "reform", + lambda: _descriptor(SimulationRole.REFORM).model_copy( + update={ + "row_identity": RowIdentity( + identifier_columns=("household.household_id",), + row_count=2, + identity_sha256="c" * 64, + ) + } + ), + "must cover the same rows", + ), + ], + ids=[ + "baseline-role", + "reform-role", + "other-report", + "same-simulation", + "other-bundle", + "other-output-plan", + "other-rows", + ], +) +def test_result_rejects_artifacts_that_do_not_form_this_report( + field, replacement, message +) -> None: + payload = _result_payload(**{field: replacement().model_dump(mode="json")}) + + with pytest.raises(ValidationError, match=message): + ReportExecutionResult.model_validate(payload) + + +def test_result_requires_a_timezone_aware_completion_time() -> None: + with pytest.raises(ValidationError, match="timezone"): + ReportExecutionResult.model_validate( + _result_payload(completed_at="2026-10-22T00:00:00") + ) + + +def test_result_rejects_repeated_requested_aggregates() -> None: + with pytest.raises(ValidationError, match="unique"): + ReportExecutionResult.model_validate( + _result_payload(requested_aggregates=["budget", "budget"]) + ) + + +def test_result_rejects_requested_aggregates_that_differ_from_the_output_plan() -> None: + # The coordinator builds the output plan from the aggregates the report + # requested, so a result naming other aggregates contradicts its own plan. + with pytest.raises(ValidationError, match="requested aggregates must match"): + ReportExecutionResult.model_validate( + _result_payload(requested_aggregates=["budget"]) + ) + + +def test_result_rejects_requested_aggregates_in_another_order_than_the_plan() -> None: + reordered = [aggregate_name.value for aggregate_name in reversed(ReportAggregate)] + + with pytest.raises(ValidationError, match="requested aggregates must match"): + ReportExecutionResult.model_validate( + _result_payload(requested_aggregates=reordered) + ) + + +def test_result_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + ReportExecutionResult.model_validate( + _result_payload(aggregate_output_uri="gs://private/aggregate.json") + ) + + +def test_failure_bounds_its_code_and_summary() -> None: + with pytest.raises(ValidationError): + ReportExecutionFailure( + evaluation_id=EVALUATION_ID, + error_code="c" * 65, + error_summary="Simulation failed", + ) + with pytest.raises(ValidationError): + ReportExecutionFailure( + evaluation_id=EVALUATION_ID, + error_code="simulation_execution_failed", + error_summary="s" * 513, + ) + + +def test_error_pickles_its_failure_as_plain_json_values() -> None: + # The error crosses Modal as a pickle. Plain JSON values in it mean the + # container's and the caller's pydantic versions need not agree. + pickled = pickle.dumps(ReportExecutionError(_failure())) + + assert b"ReportExecutionFailure" not in pickled + assert b"pydantic" not in pickled + + +def test_error_keeps_a_fixed_message_and_its_failure_through_pickle() -> None: + restored = pickle.loads(pickle.dumps(ReportExecutionError(_failure()))) + + assert isinstance(restored, ReportExecutionError) + assert restored.failure == _failure() + assert str(restored) == "Report execution failed" diff --git a/libs/policyengine-simulation-contract/tests/test_report_execution_modal_transport.py b/libs/policyengine-simulation-contract/tests/test_report_execution_modal_transport.py new file mode 100644 index 000000000..d92611b45 --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_report_execution_modal_transport.py @@ -0,0 +1,55 @@ +"""Tests that Modal raises ``ReportExecutionError`` in the caller as the same type. + +They call Modal's own exception transport, which is private to the Modal +client, so they live apart from the other report execution tests: a Modal +upgrade that moves these functions fails only this module. +""" + +from __future__ import annotations + +import asyncio +from uuid import UUID + +import pytest +from modal._serialization import pickle_exception +from modal._utils.function_utils import _process_result +from modal_proto import api_pb2 +from policyengine_simulation_contract.report_execution import ( + ReportExecutionError, + ReportExecutionFailure, +) + +EVALUATION_ID = UUID("00000000-0000-0000-0000-000000000001") + + +def _failure() -> ReportExecutionFailure: + return ReportExecutionFailure( + evaluation_id=EVALUATION_ID, + error_code="simulation_execution_failed", + error_summary="ValueError in dataset_load (baseline): bad input", + ) + + +def test_modal_raises_the_error_in_the_caller_as_the_same_type() -> None: + """Send the error through Modal's own exception transport. + + A function container pickles the exception it raised with + ``pickle_exception``, and ``FunctionCall.get`` in the caller unpickles and + raises it in ``_process_result``. Both are Modal's functions, so this + checks the installed Modal client rather than a copy of its behavior. + """ + error = ReportExecutionError(_failure()) + remote_result = api_pb2.GenericResult( + status=api_pb2.GenericResult.GENERIC_STATUS_FAILURE, + data=pickle_exception(error), + exception=repr(error), + ) + + with pytest.raises(ReportExecutionError) as raised: + asyncio.run( + _process_result(remote_result, api_pb2.DATA_FORMAT_PICKLE, None, None) + ) + + assert type(raised.value) is ReportExecutionError + assert raised.value.failure == _failure() + assert str(raised.value) == "Report execution failed" diff --git a/libs/policyengine-simulation-contract/tests/test_report_result_delivery.py b/libs/policyengine-simulation-contract/tests/test_report_result_delivery.py new file mode 100644 index 000000000..bc96cddb7 --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_report_result_delivery.py @@ -0,0 +1,79 @@ +"""Tests for how a report coordinator is told to deliver its outcome.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +import pytest +from policyengine_simulation_contract.stage12_execution import ( + ReportResultDelivery, + Stage12InvocationContext, +) +from pydantic import ValidationError + +CREATED_AT = datetime(2026, 10, 22, tzinfo=UTC) +EVALUATION_ID = UUID("00000000-0000-0000-0000-000000000001") + + +def _entrypoint_context_payload(**overrides: Any) -> dict[str, Any]: + """Return the dispatch context as the Simulation Entrypoint builds it today.""" + payload: dict[str, Any] = { + "request_id": "request-1", + "environment": "staging", + "modal_environment": "staging", + "worker_version": "6.7.5", + "modal_application": "policyengine-simulation-v2-py6-7-5", + "simulation_callable": "run_single_simulation_us", + "version_manifest_sha256": "d" * 64, + "bundle_manifest_sha256": "b" * 64, + "artifact_prefix": f"stage-12-runs/staging/2026/10/{EVALUATION_ID}", + "production_function_call_id": None, + "created_at": CREATED_AT.isoformat(), + "retention_expires_at": (CREATED_AT + timedelta(days=30)).isoformat(), + } + payload.update(overrides) + return payload + + +def test_a_context_without_a_delivery_keeps_the_comparison_path_storage() -> None: + context = Stage12InvocationContext.model_validate(_entrypoint_context_payload()) + + assert context.result_delivery is ReportResultDelivery.STAGE12_DATABASE + + +def test_a_context_can_ask_for_the_report_to_return_to_its_caller() -> None: + context = Stage12InvocationContext.model_validate( + _entrypoint_context_payload(result_delivery="return_to_caller") + ) + + assert context.result_delivery is ReportResultDelivery.RETURN_TO_CALLER + + +def test_a_returned_report_cannot_request_a_production_comparison() -> None: + # The comparison with a production result is recorded on the comparison + # path's report row, which a returned report never writes. + with pytest.raises(ValidationError, match="production comparison"): + Stage12InvocationContext.model_validate( + _entrypoint_context_payload( + result_delivery="return_to_caller", + production_function_call_id="fc-production", + ) + ) + + +def test_a_comparison_path_context_may_still_name_its_production_job() -> None: + context = Stage12InvocationContext.model_validate( + _entrypoint_context_payload(production_function_call_id="fc-production") + ) + + assert context.production_function_call_id == "fc-production" + assert context.result_delivery is ReportResultDelivery.STAGE12_DATABASE + + +def test_a_context_rejects_an_unknown_delivery() -> None: + with pytest.raises(ValidationError): + Stage12InvocationContext.model_validate( + _entrypoint_context_payload(result_delivery="object_storage") + ) diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md new file mode 100644 index 000000000..5eb232973 --- /dev/null +++ b/libs/policyengine-simulation-persistence/README.md @@ -0,0 +1,287 @@ +# 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. + +## 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 | +| `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 | + +`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. + +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. + +### 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 meet at the unique attempt number: an unforced start that +loses the insert returns the other caller's run, and a forced start adds the +attempt after it, so a forced caller always starts a run of its own (it gives +up after 10 lost inserts in a row). +`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. `RunStore.create_first_report_run` adds a report run only +while the report has none, and otherwise returns its newest: simultaneous +callers wait for each other on a transaction-level advisory lock on the report +(`pg_advisory_xact_lock`), so a report migrated without a run gets one first +run however many readers open it at once. 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, 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. + +### 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, 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 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`), 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`): +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 +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. + +`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. + +### Catalog reads + +A v2 economy run uses the region, dataset and policy rows of its catalog +version, which the Public API owns in the same database. `CatalogReader` +reads them with typed SQLAlchemy Core selects: + +- `catalog_version`: a country's catalog version for a policyengine.py + version (`tax_benefit_models`, `tax_benefit_model_versions`); +- `population`: the run's region, its default dataset or the one the + simulation names, and the household column and value that select the + region (a row filter; a region selected any other way cannot run), with + each state's filter for a US national run (`regions`, `datasets`); +- `parameter_changes`: the values the run's policy, and its dynamic, set, by + parameter name and period (a date, or an inclusive `start.end` range); a + parameter the run's catalog version does not have refuses the run + (`parameters`, `parameter_values`); +- `area_labels`: UK constituency and local-authority names by code, which a + stored result is read back with. The catalog has no map positions, so + they are read back empty. + +The runtime role needs `SELECT` on these tables (`CATALOG_READ_TABLES`). +The owner of the Public API's tables grants it, as an operator step: +`GRANT SELECT ON public.tax_benefit_models, public.tax_benefit_model_versions, +public.regions, public.datasets, public.parameters, public.parameter_values +TO simulation_runtime;` + +## 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, 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 + +| 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 `SELECT` only on the Public API tables in `CATALOG_READ_TABLES` (see "Catalog reads") | + +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, 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 +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 +`CREATE SCHEMA IF NOT EXISTS`. + +## Deployment + +On every deployment, the migration job (`migrate_simulation_database` in +`.github/workflows/simulation-deploy.reusable.yml`) runs +`.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. + +## 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. `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. 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 +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 + +```bash +uv sync --frozen --extra test +uv run --frozen 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. + +### Copies of the Public API's tables + +The tests create copies of the Public API tables sim reads and refers to +(`tests/public_api_support.py`), with only the columns sim uses. Rule +`sim.persistence.public-api-copies-match` keeps them faithful: for every table +the copies create, each column they define has the real column's type (an enum +by name and values) and nullability, and each primary key and unique +constraint they define exists under the same name with the same columns +(`tests/public_api_schema.py`). A table or column added to the copies is +checked with no other change. + +The real schema comes from the Public API itself. CI's `Public API schema` +job checks out `PolicyEngine/policyengine-api` (public, so no secret) at its +default branch, builds its tables with its own v2 migration chain +(`.github/scripts/public-api-schema-reference.sh`), and runs the tests +marked `public_api_schema` with `PUBLIC_API_SCHEMA_DATABASE_URL` naming that +database. The job follows the default branch rather than a pinned commit, so +a change there to a column sim reads fails sim's next CI run, instead of +waiting for someone to move a pin. The script prints the commit it migrated, +and a failed comparison names it. + +To run it locally: + +1. Check out the Public API at the commit to compare with (the one a failed + CI run names, to reproduce it). +2. Create an empty `policyengine_v2_alembic_test` database on a local + server; the Public API's disposable migration mode accepts no other. +3. Set `PUBLIC_API_SCHEMA_DATABASE_URL` to it, for example + `postgresql://postgres:password@127.0.0.1:5432/policyengine_v2_alembic_test`, + and run `.github/scripts/public-api-schema-reference.sh `. +4. With `SIMULATION_TEST_DATABASE_URL` naming the same server's superuser, + run `uv run --frozen pytest tests/ -m public_api_schema` here. diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml new file mode 100644 index 000000000..afad2aae2 --- /dev/null +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -0,0 +1,58 @@ +[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 = [ + # 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", +] + +[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.17.1,<2", +] +test = [ + "alembic>=1.17.1,<2", + "pytest>=8.3.4", + "pytest-cov>=6.1.1", + "pyyaml>=6.0.2", +] +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" + +[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"] +markers = [ + "postgres: needs a disposable PostgreSQL server named by SIMULATION_TEST_DATABASE_URL", + "public_api_schema: compares the test copies of the Public API's tables with the database PUBLIC_API_SCHEMA_DATABASE_URL names", +] + +[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..1b49c5d6a --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -0,0 +1,139 @@ +"""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.catalog_reads import ( + CATALOG_READ_TABLES, + CatalogError, + CatalogReader, + CatalogVersion, + PolicyParametersMissingError, + RegionFilter, + RegionPopulation, +) +from policyengine_simulation_persistence.economy_results import ( + OPTIONAL_SECTIONS, + RESULT_FIELD_STORAGE, + EconomyComparisonRecorder, + EconomyResultLabels, + GeographyLabel, + check_storable, + read_economy_comparison, + write_economy_comparison, +) +from policyengine_simulation_persistence.engine import ( + create_simulation_engine, + normalized_database_url, + read_schema_revision, +) +from policyengine_simulation_persistence.household_results import ( + HouseholdOutput, + 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, + ReportRecord, + ReportStore, +) +from policyengine_simulation_persistence.run_store import ( + 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, + ReportRunRecord, + RunStatus, + RunTrigger, + SimulationRunRecord, + StartedReportRun, + StartedSimulationRun, +) +from policyengine_simulation_persistence.schema import ( + ALEMBIC_VERSION_TABLE, + HEAD_REVISION, + RUNTIME_ROLE, + SIMULATION_SCHEMA, + simulation_metadata, +) +from policyengine_simulation_persistence.simulations import ( + NewSimulation, + SimulationKind, + SimulationRecord, + SimulationStore, +) + +__all__ = [ + "ALEMBIC_VERSION_TABLE", + "CATALOG_READ_TABLES", + "HEAD_REVISION", + "OPTIONAL_SECTIONS", + "OUTPUT_FILE_RETENTION", + "RESULT_FIELD_STORAGE", + "RUNTIME_ROLE", + "SIMULATION_SCHEMA", + "SUCCEEDED_RUN_REUSE_WINDOW", + "AbandonedDispatches", + "CatalogError", + "CatalogReader", + "CatalogVersion", + "DueSimulationCall", + "EconomyComparisonRecorder", + "EconomyResultLabels", + "GeographyLabel", + "HouseholdOutput", + "NewReport", + "NewReportRun", + "NewSimulation", + "NewSimulationRun", + "OutputFile", + "OutputFileRecord", + "PolicyParametersMissingError", + "RegionFilter", + "RegionPopulation", + "ReportKind", + "ReportRecord", + "ReportRunRecord", + "ReportStore", + "RunError", + "RunErrorCode", + "RunStatus", + "RunStore", + "RunTransitions", + "RunTrigger", + "SimulationKind", + "SimulationRecord", + "SimulationRunRecord", + "SimulationStore", + "StartedReportRun", + "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/catalog_reads.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/catalog_reads.py new file mode 100644 index 000000000..0ea038fed --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/catalog_reads.py @@ -0,0 +1,446 @@ +"""Read the Public API's catalog and policy rows a v2 economy run is resolved from. + +A v2 economy run uses one catalog version: one published policyengine.py +version of one country's model. The Simulation Entrypoint reads, from the +Public API's tables in the same database: + +- the catalog version, by country and policyengine.py version; +- the run's region and dataset rows in that version, with the household + filter that selects the region (``population``); +- the parameter values its policy, and dynamic if any, set, by parameter + name, checked against the run's version (``parameter_changes``); +- the names of the UK's constituencies and local authorities, which a stored + result is read back with (``area_labels``). + +The runtime role needs ``SELECT`` on ``CATALOG_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. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC +from typing import TYPE_CHECKING, Any + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + MetaData, + String, + Table, + Text, + Uuid, + cast, + select, +) + +from policyengine_simulation_persistence.economy_result_storage import ( + EconomyResultLabels, + GeographyLabel, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + from datetime import datetime + from uuid import UUID + + from sqlalchemy import ColumnElement, Connection, Engine, RowMapping + +catalog_metadata = MetaData() + +tax_benefit_models = Table( + "tax_benefit_models", + catalog_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("name", String(32)), +) +tax_benefit_model_versions = Table( + "tax_benefit_model_versions", + catalog_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("model_id", Uuid(as_uuid=True)), + Column("version", String(128)), +) +regions = Table( + "regions", + catalog_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("code", String(255)), + Column("label", String(255)), + # A PostgreSQL enum (``v2_region_type``) in the Public API; compared only + # through ``REGION_TYPE_TEXT``. + Column("region_type", String(64)), + Column("requires_filter", Boolean), + Column("filter_field", String(128)), + Column("filter_value", String(255)), + Column("filter_strategy", String(64)), + Column("state_code", String(16)), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True)), + Column("default_dataset_id", Uuid(as_uuid=True)), +) +datasets = Table( + "datasets", + catalog_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("name", String(255)), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True)), +) +parameters = Table( + "parameters", + catalog_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", + catalog_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("parameter_id", Uuid(as_uuid=True)), + Column("value_json", JSON), + Column("start_date", DateTime(timezone=True)), + Column("end_date", DateTime(timezone=True)), + Column("policy_id", Uuid(as_uuid=True)), + Column("dynamic_id", Uuid(as_uuid=True)), +) +# The Public API tables the runtime role reads; the operator grants SELECT. +CATALOG_READ_TABLES = tuple(catalog_metadata.tables) +# PostgreSQL has no comparison between the ``v2_region_type`` enum and a text +# value, so region types are compared as text. Reads then need neither the +# enum's list of values nor a change when the Public API adds one. +REGION_TYPE_TEXT = cast(regions.c.region_type, Text) + +ROW_FILTER = "row_filter" +STATE_REGION_TYPE = "state" +NATIONAL_REGION_TYPE = "national" +UK_AREA_REGION_TYPES = ("constituency", "local_authority") +# A refused policy's error names at most this many parameters. +MAX_LISTED_PARAMETERS = 10 + + +class CatalogError(LookupError): + """The catalog version has no such region or dataset, or cannot run it.""" + + +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 CatalogVersion: + """One published policyengine.py version of one country's catalog.""" + + id: UUID + country: str + policyengine_version: str + + +@dataclass(frozen=True) +class RegionFilter: + """The household column and stored value that select a region's households.""" + + field: str + value: str + + +@dataclass(frozen=True) +class RegionPopulation: + """A region and the dataset a run uses, as one catalog version has them. + + ``state_filters`` holds each US state's filter for a US national run, + which calculates its states in separate segments; None otherwise. + """ + + region_code: str + region_type: str + dataset_id: UUID + dataset_name: str + row_filter: RegionFilter | None + state_code: str | None + state_filters: Mapping[str, RegionFilter] | None + + +def _country(model_name: str) -> str: + return model_name.removeprefix("policyengine-") + + +def _period_key(start: datetime, end: datetime | None) -> str: + """Return the policy period key .py reads: a date, or an inclusive range. + + The Public API stores each date as midnight UTC; the dates are read in + UTC whatever the database session's time zone. + """ + start_date = start.astimezone(UTC) + if end is None: + return f"{start_date:%Y-%m-%d}" + return f"{start_date:%Y-%m-%d}.{end.astimezone(UTC):%Y-%m-%d}" + + +def _row_filter(row: RowMapping) -> RegionFilter | None: + """Return a region's household filter; raise CatalogError for another kind.""" + if not row["requires_filter"]: + return None + if row["filter_strategy"] != ROW_FILTER: + msg = ( + f"the region {row['code']} selects its households by " + f"{row['filter_strategy']}, which economy runs cannot apply" + ) + raise CatalogError(msg) + return RegionFilter(field=row["filter_field"], value=row["filter_value"]) + + +class CatalogReader: + """Typed SQLAlchemy Core reads of the Public API's catalog and policy rows.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + def _version(self, condition: ColumnElement[bool]) -> CatalogVersion | None: + with self.engine.connect() as connection: + row = ( + connection.execute( + select( + tax_benefit_model_versions.c.id, + tax_benefit_model_versions.c.version, + tax_benefit_models.c.name, + ) + .join( + tax_benefit_models, + tax_benefit_models.c.id + == tax_benefit_model_versions.c.model_id, + ) + .where(condition) + ) + .mappings() + .first() + ) + if row is None: + return None + return CatalogVersion( + id=row["id"], + country=_country(row["name"]), + policyengine_version=row["version"], + ) + + def catalog_version( + self, country: str, policyengine_version: str + ) -> CatalogVersion | None: + """Return the country's catalog version for a policyengine.py version.""" + return self._version( + (tax_benefit_models.c.name == f"policyengine-{country}") + & (tax_benefit_model_versions.c.version == policyengine_version) + ) + + def catalog_version_by_id(self, version_id: UUID) -> CatalogVersion | None: + """Return the catalog version with this ID, or None.""" + return self._version(tax_benefit_model_versions.c.id == version_id) + + def population( + self, version: CatalogVersion, region_code: str, dataset_name: str | None + ) -> RegionPopulation: + """Return the region and dataset a run of this version uses. + + ``dataset_name`` names a dataset of the version; None means the + region's default dataset. Raise CatalogError when the version has no + such region or dataset, or selects the region's households in a way + economy runs cannot apply. + """ + with self.engine.connect() as connection: + region = ( + connection.execute( + select(regions).where( + regions.c.tax_benefit_model_version_id == version.id, + regions.c.code == region_code, + ) + ) + .mappings() + .first() + ) + if region is None: + msg = ( + f"catalog version {version.policyengine_version} of " + f"{version.country} has no region {region_code}" + ) + raise CatalogError(msg) + row_filter = _row_filter(region) + if row_filter is None and region["region_type"] != NATIONAL_REGION_TYPE: + msg = ( + f"the region {region_code} has no household filter, so it " + "cannot run" + ) + raise CatalogError(msg) + dataset = self._dataset(connection, version, region, dataset_name) + state_filters = ( + self._state_filters(connection, version) + if region["region_type"] == NATIONAL_REGION_TYPE + and version.country == "us" + else None + ) + return RegionPopulation( + region_code=region_code, + region_type=region["region_type"], + dataset_id=dataset["id"], + dataset_name=dataset["name"], + row_filter=row_filter, + state_code=region["state_code"], + state_filters=state_filters, + ) + + @staticmethod + def _dataset( + connection: Connection, + version: CatalogVersion, + region: RowMapping, + dataset_name: str | None, + ) -> RowMapping: + condition = ( + datasets.c.id == region["default_dataset_id"] + if dataset_name is None + else datasets.c.name == dataset_name + ) + dataset = ( + connection.execute( + select(datasets.c.id, datasets.c.name).where( + datasets.c.tax_benefit_model_version_id == version.id, condition + ) + ) + .mappings() + .first() + ) + if dataset is None: + msg = ( + f"catalog version {version.policyengine_version} of " + f"{version.country} has no dataset {dataset_name}" + ) + raise CatalogError(msg) + return dataset + + @staticmethod + def _state_filters( + connection: Connection, version: CatalogVersion + ) -> dict[str, RegionFilter]: + rows = connection.execute( + select(regions) + .where( + regions.c.tax_benefit_model_version_id == version.id, + REGION_TYPE_TEXT == STATE_REGION_TYPE, + ) + .order_by(regions.c.code) + ).mappings() + filters: dict[str, RegionFilter] = {} + for row in rows: + row_filter = _row_filter(row) + if row_filter is None: + msg = f"the state region {row['code']} has no household filter" + raise CatalogError(msg) + filters[row["code"]] = row_filter + return filters + + def parameter_changes( + self, + catalog_version_id: UUID, + *, + policy_id: UUID | None, + dynamic_id: UUID | None, + ) -> dict[str, dict[str, Any]]: + """Return the values a policy and a dynamic set, 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 PolicyParametersMissingError when the run's + version lacks a parameter they change, and ValueError when the policy + and the dynamic both set a parameter for the same period. + """ + owners = [ + condition + for condition in ( + None + if policy_id is None + else parameter_values.c.policy_id == policy_id, + None + if dynamic_id is None + else parameter_values.c.dynamic_id == dynamic_id, + ) + if condition is not None + ] + if not owners: + return {} + with self.engine.connect() as connection: + values = connection.execute( + select( + parameters.c.name, + parameter_values.c.value_json, + parameter_values.c.start_date, + parameter_values.c.end_date, + parameter_values.c.dynamic_id, + ) + .join(parameters, parameters.c.id == parameter_values.c.parameter_id) + .where(owners[0] if len(owners) == 1 else owners[0] | owners[1]) + .order_by( + parameter_values.c.dynamic_id.is_not(None), + 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: + periods = changes.setdefault(row.name, {}) + key = _period_key(row.start_date, row.end_date) + if key in periods: + msg = f"the policy and the dynamic both set {row.name} for {key}" + raise ValueError(msg) + periods[key] = row.value_json + return changes + + def area_labels(self, catalog_version_id: UUID) -> EconomyResultLabels: + """Return the names of the version's UK constituencies and local authorities. + + A stored result names each area by its code without its type. The + catalog has no map positions, so they are left out. + """ + with self.engine.connect() as connection: + rows = connection.execute( + select(regions.c.code, regions.c.label, regions.c.region_type).where( + regions.c.tax_benefit_model_version_id == catalog_version_id, + REGION_TYPE_TEXT.in_(UK_AREA_REGION_TYPES), + ) + ).all() + labels: dict[str, dict[str, GeographyLabel]] = { + region_type: {} for region_type in UK_AREA_REGION_TYPES + } + for row in rows: + code = row.code.partition("/")[2] + labels[row.region_type][code] = GeographyLabel( + name=row.label, x=None, y=None + ) + return EconomyResultLabels( + constituencies=labels["constituency"], + local_authorities=labels["local_authority"], + ) 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 new file mode 100644 index 000000000..499e3ca68 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/economy_results.py @@ -0,0 +1,458 @@ +"""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 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 +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, +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 + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.macro_output import ( + ConstituencyImpactRecord, + DecileOutput, + IntraDecileOutput, + LaborSupplyDecileOutput, + LaborSupplyResponseOutput, + LocalAuthorityImpactRecord, + SingleYearMacroOutput, +) +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 ( + 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, + report_runs, +) + +if TYPE_CHECKING: + from uuid import UUID + + 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 ------------------------------------------------ + + +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: + # 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) + + +def _check_labor_supply(labor: LaborSupplyResponseOutput | None) -> None: + """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. + """ + if labor is None: + return + metrics = labor.decile + 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: + """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 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 + + +# --- Writing ---------------------------------------------------------------- + + +def _insert(connection: Connection, table: Table, rows: list[dict[str, Any]]) -> None: + if rows: + connection.execute(postgres_insert(table), rows) + + +def _simulation_rows( + result: SingleYearMacroOutput, side: str, run_id: UUID, report_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( + { + "report_run_id": report_run_id, + "simulation_run_id": run_id, + "side": side, + "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: [ + { + "report_run_id": report_run_id, + "simulation_run_id": run_id, + "side": side, + **{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] = [ + { + "report_run_id": report_run_id, + "simulation_run_id": run_id, + "side": side, + **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: _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]]]: + 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, + **{name: getattr(record, name) for name in UK_AREA_DISCLOSURE_COLUMNS}, + } + 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, 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: + 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, 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( + 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") + ), + result_model_version=result.model_version, + result_data_version=result.data_version, + ) + ) + + +@dataclass(frozen=True) +class EconomyComparisonRecorder: + """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.""" + 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) 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/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/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/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..5dfd974fd --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/public_api.py @@ -0,0 +1,70 @@ +"""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, UniqueConstraint, 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), +) +# 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 new file mode 100644 index 000000000..ec911e10f --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py @@ -0,0 +1,233 @@ +"""``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, + UniqueConstraint, + 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, + parse_kind, + 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", + ), + # 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) + + +@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.""" + # 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}" + 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/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..d80d8ea3c --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/result_tables.py @@ -0,0 +1,368 @@ +"""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, 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 +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 the result reports, its SPM +provenance and the list of optional sections it holds are columns on the +report run. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ( + CheckConstraint, + Column, + Double, + ForeignKeyConstraint, + Index, + 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") +# 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", + "average_household_income_change_upper", + "relative_household_income_change_lower", + "relative_household_income_change_upper", +) + + +def _values(values: tuple[str, ...]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +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), + # 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], + 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"), + ] + + +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"), + ] + + +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( + "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( + "report_run_id", + "simulation_run_id", + "side", + "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("report_run_id", "simulation_run_id", "side"), +) + +cliff_impacts = Table( + "cliff_impacts", + simulation_metadata, + *_simulation_run_key("cliff_impacts"), + *_numbers("cliff_gap", "cliff_share"), + PrimaryKeyConstraint("report_run_id", "simulation_run_id", "side"), +) + +# --- 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"), +) + +# 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), + 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", "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( + "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", + ), + *_uk_area_disclosure(), + 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", + ), + *_uk_area_disclosure(), + 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/run_store.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py new file mode 100644 index 000000000..5180d2b38 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py @@ -0,0 +1,531 @@ +"""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: an +unforced loser of the insert returns the winner's run, and a forced one adds +the attempt after it, so a forced caller always starts a run of its own. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence.output_files import ( + OutputFileRecord, + available_output_files, +) +from policyengine_simulation_persistence.reports import ReportKind, reports +from policyengine_simulation_persistence.runs import ( + UNFINISHED_STATUSES, + NewReportRun, + NewSimulationRun, + ReportRunRecord, + RunStatus, + SimulationRunRecord, + StartedReportRun, + StartedSimulationRun, + report_runs, + simulation_runs, +) +from policyengine_simulation_persistence.simulations import ( + SimulationKind, + simulations, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Collection + + 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) +# Each lost insert means another caller committed an attempt; a forced start +# gives up after this many, which only a runaway of concurrent starts reaches. +FORCED_START_INSERT_LIMIT = 10 +# The first of the two keys of the transaction-level advisory locks that +# serialise first report runs; the second is the hash of the report ID. +FIRST_REPORT_RUN_LOCK_SPACE = 2_020_101 + + +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. When another + caller adds the same attempt at the same time, an unforced start + returns that run. ``force`` always adds an attempt of its own, after + any attempt another caller added. 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) + for _ in range(FORCED_START_INSERT_LIMIT): + attempt = 1 if latest is None else latest.attempt + 1 + created = self._insert_attempt( + connection, new_run, simulation["kind"], attempt, now + ) + if created is not None: + return StartedSimulationRun(run=created, created=True) + # Another caller added this attempt after the read above; the + # insert waited for it to commit, so it is visible now. + latest = self._latest_simulation_run( + connection, + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + if latest is None: # pragma: no cover - the conflict proves a row + msg = "the conflicting simulation run disappeared" + raise RuntimeError(msg) + if not force: + return StartedSimulationRun(run=latest, created=False) + msg = ( + f"{FORCED_START_INSERT_LIMIT} concurrent starts of simulation " + f"{new_run.simulation_id} took every attempt a forced start tried" + ) + raise RuntimeError(msg) + + @staticmethod + def _insert_attempt( + connection: Connection, + new_run: NewSimulationRun, + simulation_kind: str, + attempt: int, + now: datetime, + ) -> SimulationRunRecord | None: + """Insert this attempt, or return None when another caller has it.""" + row = ( + 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() + ) + return None if row is None else SimulationRunRecord.from_row(row) + + @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. + """ + with self.engine.begin() as connection: + return self._insert_report_run(connection, new_report_run) + + def create_first_report_run(self, new_report_run: NewReportRun) -> StartedReportRun: + """Add the report's first report run, or return its newest one. + + Callers that start a report's first run at the same time, such as + two first opens of a report migrated without a run, wait for each + other on a transaction-level advisory lock on the report, so only + the first adds a run and the others return it. Raise as + ``create_report_run`` does. + """ + with self.engine.begin() as connection: + connection.execute( + select( + func.pg_advisory_xact_lock( + FIRST_REPORT_RUN_LOCK_SPACE, + func.hashtext(str(new_report_run.report_id)), + ) + ) + ) + newest = self._newest_report_run(connection, new_report_run.report_id) + if newest is not None: + return StartedReportRun(run=newest, created=False) + return StartedReportRun( + run=self._insert_report_run(connection, new_report_run), created=True + ) + + def _insert_report_run( + self, connection: Connection, new_report_run: NewReportRun + ) -> ReportRunRecord: + now = self._clock() + 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 report_runs(self, report_id: UUID) -> list[ReportRunRecord]: + """Return the report's runs, newest first.""" + with self.engine.connect() as connection: + rows = connection.execute( + select(report_runs) + .where(report_runs.c.report_id == report_id) + .order_by(report_runs.c.created_at.desc(), report_runs.c.id.desc()) + ).mappings() + return [ReportRunRecord.from_row(row) for row in rows] + + def comparisons_ready_to_start( + self, + *, + simulation_run_ids: Collection[UUID] | None = None, + limit: int = 100, + ) -> list[ReportRunRecord]: + """Return pending comparison steps whose two simulation runs succeeded. + + ``simulation_run_ids`` keeps only the report runs that compare one of + those runs, such as the runs a call just finished. + """ + baseline = simulation_runs.alias("baseline_run") + reform = simulation_runs.alias("reform_run") + conditions = [ + report_runs.c.comparison_status == RunStatus.PENDING.value, + baseline.c.status == RunStatus.SUCCEEDED.value, + reform.c.status == RunStatus.SUCCEEDED.value, + ] + if simulation_run_ids is not None: + wanted = set(simulation_run_ids) + conditions.append( + report_runs.c.baseline_simulation_run_id.in_(wanted) + | report_runs.c.reform_simulation_run_id.in_(wanted) + ) + with self.engine.connect() as connection: + rows = connection.execute( + select(report_runs) + .join( + baseline, baseline.c.id == report_runs.c.baseline_simulation_run_id + ) + .join(reform, reform.c.id == report_runs.c.reform_simulation_run_id) + .where(*conditions) + .order_by(report_runs.c.created_at) + .limit(limit) + ).mappings() + return [ReportRunRecord.from_row(row) for row in rows] + + def pending_simulation_runs( + self, + *, + created_before: datetime, + simulation_kind: SimulationKind | None = None, + limit: int = 100, + ) -> list[SimulationRunRecord]: + """Return runs still pending that were created before ``created_before``. + + A run whose submitting instance stopped before starting its call stays + pending; the safety check starts it. ``simulation_kind`` keeps only + runs of that kind. + """ + conditions = [ + simulation_runs.c.status == RunStatus.PENDING.value, + simulation_runs.c.created_at < created_before, + ] + if simulation_kind is not None: + conditions.append( + simulation_runs.c.simulation_kind == simulation_kind.value + ) + with self.engine.connect() as connection: + rows = connection.execute( + select(simulation_runs) + .where(*conditions) + .order_by(simulation_runs.c.created_at) + .limit(limit) + ).mappings() + return [SimulationRunRecord.from_row(row) for row in rows] + + def report_runs_using(self, simulation_run_id: UUID) -> list[ReportRunRecord]: + """Return the report runs comparing this simulation run, oldest first.""" + with self.engine.connect() as connection: + rows = connection.execute( + select(report_runs) + .where( + (report_runs.c.baseline_simulation_run_id == simulation_run_id) + | (report_runs.c.reform_simulation_run_id == simulation_run_id) + ) + .order_by(report_runs.c.created_at, report_runs.c.id) + ).mappings() + return [ReportRunRecord.from_row(row) for row in rows] + + def simulation_output_file( + self, + simulation_run_id: UUID, + role: str, + *, + now: datetime | None = None, + ) -> OutputFileRecord | None: + """Return the newest file of ``role`` the run stored that is still offered.""" + with self.engine.connect() as connection: + files = available_output_files( + connection, + now=self._clock() if now is None else now, + simulation_run_id=simulation_run_id, + ) + matching = [file for file in files if file.role == role] + return matching[-1] if matching else None + + 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: + return self._newest_report_run_where(connection, condition) + + @classmethod + def _newest_report_run( + cls, connection: Connection, report_id: UUID + ) -> ReportRunRecord | None: + return cls._newest_report_run_where( + connection, report_runs.c.report_id == report_id + ) + + @staticmethod + def _newest_report_run_where( + connection: Connection, condition: ColumnElement[bool] + ) -> ReportRunRecord | None: + 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/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py new file mode 100644 index 000000000..2c6f55c95 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -0,0 +1,618 @@ +"""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; +- 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 + 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, func, or_, 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, or the acknowledged + # call could not be recorded (the entrypoint then tries to cancel it). + DISPATCH_FAILED = "dispatch_failed" + # No Modal call was recorded in time after the dispatch was claimed. + 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" + # A comparison step whose baseline or reform simulation run failed. + SIMULATION_RUN_FAILED = "simulation_run_failed" + # The run cannot run on its catalog version, for example because its + # policy changes a parameter the version does not have. + INVALID_INPUT = "invalid_input" + + +@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 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]: + """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 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 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]: + """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 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, + } + + 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( + self, *, claimed_before: datetime + ) -> AbandonedDispatches: + """Fail runs and comparison steps left in ``dispatching``. + + 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 = "No Modal call was recorded for the dispatch in time." + 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/src/policyengine_simulation_persistence/runs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py new file mode 100644 index 000000000..843277f85 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -0,0 +1,508 @@ +"""``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 sqlalchemy.dialects.postgresql import ARRAY, JSONB + +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), +) +# A recording pass 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), + # Set with an economy comparison's result rows (item 1.07): the optional + # 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( + "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( + "(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, +) +# 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, + 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 + + +@dataclass(frozen=True) +class StartedReportRun: + """The report run a first start resolved to, and whether this call created it.""" + + run: ReportRunRecord + 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 new file mode 100644 index 000000000..00ec22e10 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -0,0 +1,44 @@ +"""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" +# 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" + +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/src/policyengine_simulation_persistence/simulations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py new file mode 100644 index 000000000..f417b768e --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py @@ -0,0 +1,362 @@ +"""``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"), + # 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], + 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 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): + 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.""" + # 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) + 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 find_or_create(self, new_simulation: NewSimulation) -> SimulationRecord: + """Return the oldest simulation with this definition, or create it. + + Runs are reused per simulation, so a submission that describes a + simulation already defined, such as current law in California, uses + that simulation and can reuse its runs. Two concurrent submissions of + a new definition can each create one; both work, and later + submissions use the older. + """ + values = _new_simulation_values(new_simulation) + definition = [ + simulations.c[name].is_not_distinct_from(values[name]) + for name in ( + "kind", + "country", + "policy_id", + "dynamic_id", + "household_id", + "region_code", + "dataset_name", + ) + ] + with self.engine.begin() as connection: + row = ( + connection.execute( + select(simulations) + .where(*definition) + .order_by(simulations.c.created_at, simulations.c.id) + .limit(1) + ) + .mappings() + .first() + ) + if row is None: + row = ( + connection.execute( + postgres_insert(simulations) + .values(**values) + .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/catalog_support.py b/libs/policyengine-simulation-persistence/tests/catalog_support.py new file mode 100644 index 000000000..0deb4b41f --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/catalog_support.py @@ -0,0 +1,299 @@ +"""Public API catalog and policy rows for the catalog read tests. + +Two published PolicyEngine.py versions of the US catalog (6.5.0 and 6.7.5) +and one of the UK's (6.7.5), each with its own regions, datasets and +parameters, as each publication creates new rows; a US policy saved on 6.5.0, +another naming a parameter 6.7.5 no longer has, and a dynamic. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from public_api_support import superuser_engine +from sqlalchemy import text + +CTC_AMOUNT = "gov.irs.credits.ctc.amount.base[0].amount" +INCOME_ELASTICITY = "gov.simulation.labor_supply_responses.elasticities.income" +RETIRED_PARAMETER = "gov.irs.credits.retired_credit.amount" +US_STATES = { + "state/ca": ("6", "CA"), + "state/ny": ("36", "NY"), + "state/de": ("10", "DE"), +} +ENGLAND_REGIONS = '["NORTH_EAST","LONDON"]' + + +@dataclass(frozen=True) +class CatalogRows: + """The identifiers of the inserted catalog and policy rows.""" + + us_version_id: UUID + older_us_version_id: UUID + uk_version_id: UUID + us_national_dataset_id: UUID + us_state_dataset_id: UUID + uk_dataset_id: UUID + policy_id: UUID + retired_policy_id: UUID + dynamic_id: UUID + + +def _datetime(day: str) -> datetime: + return datetime.fromisoformat(day).replace(tzinfo=UTC) + + +class _Inserts: + """Collect insert statements and run them as the tables' owner.""" + + def __init__(self) -> None: + self.statements: list[tuple[str, dict[str, object]]] = [] + + def add(self, statement: str, **parameters: object) -> None: + self.statements.append((statement, parameters)) + + def model(self, name: str) -> UUID: + model_id = uuid4() + self.add( + "INSERT INTO public.tax_benefit_models (id, name) VALUES (:id, :name)", + id=model_id, + name=name, + ) + return model_id + + def version(self, model_id: UUID, version: str) -> UUID: + version_id = uuid4() + self.add( + "INSERT INTO public.tax_benefit_model_versions (id, model_id, version) " + "VALUES (:id, :model, :version)", + id=version_id, + model=model_id, + version=version, + ) + return version_id + + def dataset(self, version_id: UUID, name: str) -> UUID: + dataset_id = uuid4() + self.add( + "INSERT INTO public.datasets (id, name, tax_benefit_model_version_id) " + "VALUES (:id, :name, :version)", + id=dataset_id, + name=name, + version=version_id, + ) + return dataset_id + + def region( # noqa: PLR0913 # one keyword per region column + self, + version_id: UUID, + code: str, + dataset_id: UUID, + *, + label: str, + region_type: str, + row_filter: tuple[str, str] | None = None, + filter_strategy: str = "row_filter", + state_code: str | None = None, + ) -> None: + self.add( + "INSERT INTO public.regions (id, code, tax_benefit_model_version_id, " + "default_dataset_id, label, region_type, requires_filter, filter_field, " + "filter_value, filter_strategy, state_code) VALUES (gen_random_uuid(), " + ":code, :version, :dataset, :label, :type, :filtered, :field, :value, " + ":strategy, :state)", + code=code, + version=version_id, + dataset=dataset_id, + label=label, + type=region_type, + filtered=row_filter is not None, + field=None if row_filter is None else row_filter[0], + value=None if row_filter is None else row_filter[1], + strategy=None if row_filter is None else filter_strategy, + state=state_code, + ) + + def parameter(self, version_id: UUID, name: str) -> UUID: + parameter_id = uuid4() + self.add( + "INSERT INTO public.parameters (id, name, tax_benefit_model_version_id) " + "VALUES (:id, :name, :version)", + id=parameter_id, + name=name, + version=version_id, + ) + return parameter_id + + def value( # noqa: PLR0913 # one keyword per value column + self, + parameter_id: UUID, + value: object, + start: str, + end: str | None = None, + *, + policy_id: UUID | None = None, + dynamic_id: UUID | None = None, + ) -> None: + self.add( + "INSERT INTO public.parameter_values (id, parameter_id, value_json, " + "start_date, end_date, policy_id, dynamic_id) VALUES (gen_random_uuid(), " + ":parameter, CAST(:value AS jsonb), :start, :end, :policy, :dynamic)", + parameter=parameter_id, + value=json.dumps(value), + start=_datetime(start), + end=None if end is None else _datetime(end), + policy=policy_id, + dynamic=dynamic_id, + ) + + def run(self, superuser_url: str) -> None: + engine = superuser_engine(superuser_url) + try: + with engine.begin() as connection: + for statement, parameters in self.statements: + connection.execute(text(statement), parameters) + finally: + engine.dispose() + + +def _us_regions( + inserts: _Inserts, version_id: UUID, national: UUID, states: UUID +) -> None: + inserts.region( + version_id, "us", national, label="United States", region_type="national" + ) + for code, (fips, state_code) in US_STATES.items(): + inserts.region( + version_id, + code, + states, + label=code, + region_type="state", + row_filter=("state_fips", fips), + state_code=state_code, + ) + inserts.region( + version_id, + "congressional_district/CA-01", + states, + label="California's 1st congressional district", + region_type="congressional_district", + row_filter=("congressional_district_geoid", "601"), + state_code="CA", + ) + # The current catalog publishes US places with no household filter. + inserts.region( + version_id, + "place/CA-44000", + national, + label="A place without a household filter", + region_type="place", + state_code="CA", + ) + inserts.region( + version_id, + "place/CA-57000", + national, + label="A place filtered by weights", + region_type="place", + row_filter=("place_fips", "57000"), + filter_strategy="weight_replacement", + state_code="CA", + ) + + +def _uk_regions(inserts: _Inserts, version_id: UUID, dataset: UUID) -> None: + inserts.region( + version_id, "uk", dataset, label="United Kingdom", region_type="national" + ) + inserts.region( + version_id, + "country/wales", + dataset, + label="Wales", + region_type="country", + row_filter=("region", "WALES"), + ) + inserts.region( + version_id, + "country/england", + dataset, + label="England", + region_type="country", + row_filter=("region", ENGLAND_REGIONS), + ) + inserts.region( + version_id, + "constituency/E14001063", + dataset, + label="Aldershot", + region_type="constituency", + row_filter=("constituency_code_oa", "E14001063"), + ) + inserts.region( + version_id, + "local_authority/E06000063", + dataset, + label="Cumberland", + region_type="local_authority", + row_filter=("la_code_oa", "E06000063"), + ) + + +def insert_catalog_rows(superuser_url: str) -> CatalogRows: + """Insert the catalog versions, regions, datasets, parameters and policies.""" + inserts = _Inserts() + us_model = inserts.model("policyengine-us") + uk_model = inserts.model("policyengine-uk") + us_version = inserts.version(us_model, "6.7.5") + older_us_version = inserts.version(us_model, "6.5.0") + uk_version = inserts.version(uk_model, "6.7.5") + us_national = inserts.dataset(us_version, "populace_us_2024") + us_states = inserts.dataset(us_version, "populace_us_2024_acs_local") + older_national = inserts.dataset(older_us_version, "populace_us_2024") + uk_dataset = inserts.dataset(uk_version, "enhanced_frs_2023_24") + _us_regions(inserts, us_version, us_national, us_states) + inserts.region( + older_us_version, + "state/ca", + older_national, + label="California", + region_type="state", + row_filter=("state_fips", "6"), + state_code="CA", + ) + _uk_regions(inserts, uk_version, uk_dataset) + for name in (CTC_AMOUNT, INCOME_ELASTICITY): + inserts.parameter(us_version, name) + older_ctc = inserts.parameter(older_us_version, CTC_AMOUNT) + older_elasticity = inserts.parameter(older_us_version, INCOME_ELASTICITY) + retired = inserts.parameter(older_us_version, RETIRED_PARAMETER) + policy_id, retired_policy_id, dynamic_id = uuid4(), uuid4(), uuid4() + for row_id in (policy_id, retired_policy_id): + inserts.add( + "INSERT INTO public.policies (id, country_id, " + "tax_benefit_model_version_id) VALUES (:id, 'us', :version)", + id=row_id, + version=older_us_version, + ) + inserts.add("INSERT INTO public.dynamics (id) VALUES (:id)", id=dynamic_id) + inserts.value(older_ctc, 3000, "2026-01-01", policy_id=policy_id) + inserts.value(older_ctc, 2500, "2025-01-01", "2025-12-31", policy_id=policy_id) + inserts.value(older_ctc, 3000, "2026-01-01", policy_id=retired_policy_id) + inserts.value(retired, 100, "2026-01-01", policy_id=retired_policy_id) + inserts.value(older_elasticity, 0.1, "2026-01-01", dynamic_id=dynamic_id) + inserts.run(superuser_url) + return CatalogRows( + us_version_id=us_version, + older_us_version_id=older_us_version, + uk_version_id=uk_version, + us_national_dataset_id=us_national, + us_state_dataset_id=us_states, + uk_dataset_id=uk_dataset, + policy_id=policy_id, + retired_policy_id=retired_policy_id, + dynamic_id=dynamic_id, + ) diff --git a/libs/policyengine-simulation-persistence/tests/conftest.py b/libs/policyengine-simulation-persistence/tests/conftest.py new file mode 100644 index 000000000..12a4821b6 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/conftest.py @@ -0,0 +1,152 @@ +"""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 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 + +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 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 a database owned by the migrator, and drop it afterwards. + + 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") + 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)), + ) + create_public_api_tables( + database.superuser_url, grant_references=references_granted + ) + 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() + + +@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/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..832f978a9 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_local_authority_2026.json @@ -0,0 +1,319 @@ +{ + "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": [ + { + "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": 0, + "y": 0, + "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..97e364348 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_nation_scotland_2026.json @@ -0,0 +1,1552 @@ +{ + "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": [ + { + "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": 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, + "y": 0, + "average_household_income_change": 1215.908995067085, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -377.999828418476, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1974.7197361654237, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -649.8105518108947, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -387.339732156089, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -726.0333413494302, + "relative_household_income_change": -0.0, + "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": 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, + "y": 0, + "average_household_income_change": -1766.27589639709, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -1656.9844230967562, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -1568.4105912709422, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -1540.3301530676376, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -49.71783554546061, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -1713.485408027081, + "relative_household_income_change": 1e-12, + "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": 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, + "y": 0, + "average_household_income_change": 886.6824618994942, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 1847.8852194008336, + "relative_household_income_change": 0.970116107552746, + "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, + "y": 0, + "average_household_income_change": -199.63383446378157, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1286.6748534572503, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -1170.758388551763, + "relative_household_income_change": 0.8412079197691499, + "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, + "y": 0, + "average_household_income_change": -721.0569534926518, + "relative_household_income_change": -0.0, + "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": 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, + "y": 0, + "average_household_income_change": 1823.701833262151, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": 585.4829596118924, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -10.380093653008071, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -256.4312080701563, + "relative_household_income_change": 0.6432241717762336, + "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, + "y": 0, + "average_household_income_change": 1702.7522243659537, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": 1224.4288663412387, + "relative_household_income_change": 1e-12, + "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": 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, + "y": 0, + "average_household_income_change": -1787.784293656903, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -712.4863332789974, + "relative_household_income_change": 0.8140841787567573, + "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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 16, + "y": 1, + "average_household_income_change": -1580.6126310753368, + "relative_household_income_change": 0.8216310930495441, + "population": 64646.038428561056 + } + ], + "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": 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, + "y": 0, + "average_household_income_change": -760.4907483529573, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1353.896856076334, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1879.016683323623, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -629.0130927662503, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 874.6099793547413, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 467.98635714846614, + "relative_household_income_change": 1e-12, + "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": 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, + "y": 0, + "average_household_income_change": -838.1639537967227, + "relative_household_income_change": 0.42674518552708585, + "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, + "y": 0, + "average_household_income_change": -1308.258634844165, + "relative_household_income_change": 0.7317258270864375, + "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, + "y": 0, + "average_household_income_change": -1200.3987427447091, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -538.9775499790547, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -521.6966583670524, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -403.72167393983773, + "relative_household_income_change": 1e-12, + "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": 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, + "y": 0, + "average_household_income_change": 1912.9330818653293, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 676.9488735769369, + "relative_household_income_change": 0.22230810403099188, + "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, + "y": 0, + "average_household_income_change": 736.3951699342284, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -892.5830346459045, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 1871.832185732079, + "relative_household_income_change": 0.9315108544036715, + "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, + "y": 0, + "average_household_income_change": -1775.413352074153, + "relative_household_income_change": -3.5e-9, + "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": 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, + "y": 0, + "average_household_income_change": 253.80086043629467, + "relative_household_income_change": 0.16846215105615936, + "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, + "y": 0, + "average_household_income_change": -1851.6056943936076, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -1449.2733170436777, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -872.6789126607173, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -1311.9645179858242, + "relative_household_income_change": 0.9912178495741759, + "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, + "y": 0, + "average_household_income_change": 576.031133075289, + "relative_household_income_change": 0.7038678747725524, + "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": 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, + "y": 0, + "average_household_income_change": -1343.8459777407186, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -26.106472316017815, + "relative_household_income_change": -3.5e-9, + "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": 31, + "y": 0, + "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..414ea324a --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/economy_results/uk_national_2026.json @@ -0,0 +1,14516 @@ +{ + "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": [ + { + "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": 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, + "y": 0, + "average_household_income_change": 1766.892980665629, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 1979.1207253554358, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -241.7288234592716, + "relative_household_income_change": 0.0011309834882184955, + "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, + "y": 0, + "average_household_income_change": -1955.5874396191487, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 500.32029373535636, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 1429.7516356012716, + "relative_household_income_change": -3.5e-9, + "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": 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, + "y": 0, + "average_household_income_change": -1430.855530589559, + "relative_household_income_change": 0.5204399501519741, + "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, + "y": 0, + "average_household_income_change": -879.9490006950084, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 626.256099809495, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -1182.6118333676216, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -1313.786038062641, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 522.77933357376, + "relative_household_income_change": -0.0, + "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": 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, + "y": 0, + "average_household_income_change": 1838.9633545881009, + "relative_household_income_change": 0.8530915769275014, + "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, + "y": 0, + "average_household_income_change": 1807.801459835364, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 1302.60169056573, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 684.7633481422735, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 488.326844642812, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -1826.7030677545897, + "relative_household_income_change": -0.0, + "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": 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, + "y": 0, + "average_household_income_change": -166.75177258327656, + "relative_household_income_change": 0.046193690494361284, + "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, + "y": 0, + "average_household_income_change": -1397.6894273125588, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1166.7618642594055, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 1103.7650090255484, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": 1390.0054628643597, + "relative_household_income_change": 0.19212060592154867, + "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, + "y": 0, + "average_household_income_change": -550.2630807394695, + "relative_household_income_change": 0.0, + "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": 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, + "y": 0, + "average_household_income_change": -1807.9185090422197, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": 1181.9497773458966, + "relative_household_income_change": 0.6841082101901497, + "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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 9, + "y": 16, + "average_household_income_change": 317.6664618952065, + "relative_household_income_change": -3.5e-9, + "population": 56840.72937490232 + } + ], + "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": 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, + "y": 0, + "average_household_income_change": -69.21670028623072, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": 912.1297309164911, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": 1910.1807474188765, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 969.7714464687679, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -1207.175754642046, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": 588.1157473314702, + "relative_household_income_change": -3.5e-9, + "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": 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, + "y": 0, + "average_household_income_change": 212.13946976047592, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1378.918327125695, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -623.1384398880615, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1682.4344088991313, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -1793.1064434964794, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -972.1255530448434, + "relative_household_income_change": -3.5e-9, + "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": 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, + "y": 0, + "average_household_income_change": -538.4535593560624, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": 1745.5093965364488, + "relative_household_income_change": -0.0, + "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, + "y": 0, + "average_household_income_change": -1613.72155725294, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": -1690.1448876694994, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -157.98504358729159, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": 1070.4210969196931, + "relative_household_income_change": -3.5e-9, + "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": 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, + "y": 0, + "average_household_income_change": 1042.6216477340079, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": -356.59948313574176, + "relative_household_income_change": 0.0, + "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, + "y": 0, + "average_household_income_change": 255.29861692466403, + "relative_household_income_change": 0.7232325273104853, + "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, + "y": 0, + "average_household_income_change": -561.9967800290389, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -109.1144097657053, + "relative_household_income_change": 1e-12, + "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, + "y": 0, + "average_household_income_change": -1367.295021366755, + "relative_household_income_change": 1e-12, + "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": 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, + "y": 0, + "average_household_income_change": -274.2402568191693, + "relative_household_income_change": -3.5e-9, + "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, + "y": 0, + "average_household_income_change": 723.445821990056, + "relative_household_income_change": -0.0, + "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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 39, + "y": 8, + "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/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/migration_support.py b/libs/policyengine-simulation-persistence/tests/migration_support.py new file mode 100644 index 000000000..8efb3d13e --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/migration_support.py @@ -0,0 +1,75 @@ +"""Paths and names shared by the migration and store tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +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 + +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)) + + +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 + detail = str(error).strip().splitlines() + return f"{name} failed: {type(error).__name__}" + ( + f": {detail[0]}" if detail else "" + ) + 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 [] + + +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/ownership_checks.py b/libs/policyengine-simulation-persistence/tests/ownership_checks.py new file mode 100644 index 000000000..6ae57384a --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/ownership_checks.py @@ -0,0 +1,273 @@ +"""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" +# Any variable named like a database URL (DATABASE_URL itself included), or a +# PostgreSQL URL written out. +DATABASE_CREDENTIAL = re.compile( + 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. +COMPARISON_PATH_CREDENTIALS = frozenset( + {"STAGE12_DATABASE_URL", "STAGE12_DATABASE_URL_SECRET_NAME"} +) +MODAL_SECRET_COMMAND = re.compile(r"\bmodal\s+secret\s+create\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]: + 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 _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) + 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 _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 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, 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. + """ + 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 database credential other than the comparison " + f"path's: {', '.join(carried)}" + for name in sorted(modal_scripts) + if (carried := _foreign_credentials(script_texts[name])) + ] + for workflow_name, workflow_text in sorted(workflow_texts.items()): + 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/public_api_schema.py b/libs/policyengine-simulation-persistence/tests/public_api_schema.py new file mode 100644 index 000000000..50ebfcf61 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/public_api_schema.py @@ -0,0 +1,227 @@ +"""Describe Public API tables, to check sim's test copies against the real ones. + +Sim's tests create copies of the Public API tables sim reads and refers to +(``public_api_support``), with only the columns sim uses. A copy that differs +from the real table hides bugs: a ``varchar`` copy of the enum column +``regions.region_type`` let a comparison PostgreSQL refuses pass every test. + +``describe_tables`` reads, from a database's catalog, each table's columns +(their type, with an enum named as ``enum ``, and whether they allow +null), its primary key and unique constraints (by name, with their columns), +and the values of each enum its columns use. ``schema_differences`` lists +every way the copies differ from the real tables: for each table the copies +create, each column they define must have the real column's type and +nullability, each primary key and unique constraint they define must exist +under the same name with the same columns, and each enum must have the same +values in the same order. Columns, constraints and tables the copies leave +out are not compared, so a table or column added to the copies is checked +with no change here. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sqlalchemy import text + +if TYPE_CHECKING: + from collections.abc import Collection + from pathlib import Path + + from sqlalchemy import Connection + +ENUM_PREFIX = "enum " + +_COLUMNS = text( + "SELECT c.relname AS table_name, a.attname AS column_name, " + "CASE WHEN t.typtype = 'e' THEN 'enum ' || t.typname " + "ELSE format_type(a.atttypid, a.atttypmod) END AS column_type, " + "NOT a.attnotnull AS nullable " + "FROM pg_attribute a " + "JOIN pg_class c ON c.oid = a.attrelid " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "JOIN pg_type t ON t.oid = a.atttypid " + "WHERE n.nspname = 'public' AND c.relkind = 'r' " + "AND a.attnum > 0 AND NOT a.attisdropped " + "AND c.relname = ANY(:table_names) " + "ORDER BY c.relname, a.attnum" +) +_CONSTRAINTS = text( + "SELECT c.relname AS table_name, con.conname AS constraint_name, " + "array_agg(a.attname::text ORDER BY key.position) AS column_names " + "FROM pg_constraint con " + "JOIN pg_class c ON c.oid = con.conrelid " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY " + "AS key(attribute_number, position) " + "JOIN pg_attribute a ON a.attrelid = con.conrelid " + "AND a.attnum = key.attribute_number " + "WHERE n.nspname = 'public' AND con.contype IN ('p', 'u') " + "AND c.relname = ANY(:table_names) " + "GROUP BY c.relname, con.conname" +) +_ENUM_VALUES = text( + "SELECT t.typname AS enum_name, " + "array_agg(e.enumlabel::text ORDER BY e.enumsortorder) AS enum_values " + "FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "JOIN pg_namespace n ON n.oid = t.typnamespace " + "WHERE n.nspname = 'public' GROUP BY t.typname" +) +_PUBLIC_TABLES = text( + "SELECT c.relname FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY c.relname" +) + + +@dataclass(frozen=True) +class ColumnShape: + """A column's type, as PostgreSQL formats it, and whether it allows null.""" + + column_type: str + nullable: bool + + +@dataclass(frozen=True) +class TableShape: + """A table's columns, and its primary key and unique constraints by name.""" + + columns: dict[str, ColumnShape] + unique_constraints: dict[str, tuple[str, ...]] + + +@dataclass(frozen=True) +class SchemaShape: + """Tables by name, and the values of each enum their columns use.""" + + tables: dict[str, TableShape] + enums: dict[str, tuple[str, ...]] + + +def public_tables(connection: Connection) -> list[str]: + """Return the names of the tables in the ``public`` schema.""" + return list(connection.execute(_PUBLIC_TABLES).scalars()) + + +def describe_tables( + connection: Connection, table_names: Collection[str] +) -> SchemaShape: + """Describe these tables of the ``public`` schema from the database's catalog.""" + names = sorted(table_names) + columns: dict[str, dict[str, ColumnShape]] = {} + for row in connection.execute(_COLUMNS, {"table_names": names}).mappings(): + columns.setdefault(row["table_name"], {})[row["column_name"]] = ColumnShape( + column_type=row["column_type"], nullable=row["nullable"] + ) + constraints: dict[str, dict[str, tuple[str, ...]]] = {} + for row in connection.execute(_CONSTRAINTS, {"table_names": names}).mappings(): + constraints.setdefault(row["table_name"], {})[row["constraint_name"]] = tuple( + row["column_names"] + ) + used_enums = { + column.column_type.removeprefix(ENUM_PREFIX) + for table_columns in columns.values() + for column in table_columns.values() + if column.column_type.startswith(ENUM_PREFIX) + } + enums = { + row["enum_name"]: tuple(row["enum_values"]) + for row in connection.execute(_ENUM_VALUES).mappings() + if row["enum_name"] in used_enums + } + return SchemaShape( + tables={ + name: TableShape( + columns=columns[name], unique_constraints=constraints.get(name, {}) + ) + for name in names + if name in columns + }, + enums=enums, + ) + + +def _table_differences(name: str, copy: TableShape, real: TableShape) -> list[str]: + found = [] + for column_name, copied in copy.columns.items(): + actual = real.columns.get(column_name) + if actual is None: + found.append( + f"{name}.{column_name}: the Public API's table has no such column" + ) + continue + if copied.column_type != actual.column_type: + found.append( + f"{name}.{column_name}: the copy is {copied.column_type}, " + f"the Public API's is {actual.column_type}" + ) + if copied.nullable != actual.nullable: + found.append( + f"{name}.{column_name}: the copy " + f"{'allows' if copied.nullable else 'refuses'} null, the Public " + f"API's {'allows' if actual.nullable else 'refuses'} it" + ) + for constraint_name, copied_columns in copy.unique_constraints.items(): + actual_columns = real.unique_constraints.get(constraint_name) + if actual_columns is None: + found.append( + f"{name}: the Public API's table has no constraint " + f"{constraint_name} ({', '.join(copied_columns)})" + ) + elif actual_columns != copied_columns: + found.append( + f"{name}: {constraint_name} is ({', '.join(copied_columns)}) in the " + f"copy, ({', '.join(actual_columns)}) in the Public API" + ) + return found + + +def schema_differences(copies: SchemaShape, real: SchemaShape) -> list[str]: + """Return every way the copies differ from the real tables, or nothing.""" + found = [] + for name, copy in copies.tables.items(): + actual = real.tables.get(name) + if actual is None: + found.append(f"{name}: the Public API has no such table") + continue + found.extend(_table_differences(name, copy, actual)) + for enum_name, copied_values in copies.enums.items(): + actual_values = real.enums.get(enum_name) + if actual_values is None: + # Enum columns are named without their schema, so an enum moved out + # of the public schema would otherwise pass unchecked. + found.append( + f"enum {enum_name}: the Public API has no such enum in its public " + "schema" + ) + elif actual_values != copied_values: + found.append( + f"enum {enum_name}: the copy has ({', '.join(copied_values)}), the " + f"Public API has ({', '.join(actual_values)})" + ) + return found + + +def schema_from_json(path: Path) -> SchemaShape: + """Read a description stored as JSON, as the rule's proof fixtures are.""" + payload = json.loads(path.read_text(encoding="utf-8")) + return SchemaShape( + tables={ + name: TableShape( + columns={ + column_name: ColumnShape( + column_type=column["type"], nullable=column["nullable"] + ) + for column_name, column in table["columns"].items() + }, + unique_constraints={ + constraint_name: tuple(columns) + for constraint_name, columns in table["unique_constraints"].items() + }, + ) + for name, table in payload["tables"].items() + }, + enums={name: tuple(values) for name, values in payload["enums"].items()}, + ) 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..ffc94bc0a --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/public_api_support.py @@ -0,0 +1,283 @@ +"""Copies of the Public API tables sim refers to and reads, with only sim's columns. + +In production these tables belong to the Public API's own migration chain, in +the same database as the simulation schema. The tests create the columns and +unique constraints the simulation schema's foreign keys use, and the columns +the catalog reads select (``catalog_reads``), owned by the superuser, and +grant the migrator ``REFERENCES`` on them, as the operator does in +production. The runtime role may only read the catalog and policy tables +(``CATALOG_READ_TABLES``). + +Every column, primary key and unique constraint the copies define has the +type, nullability and name the Public API's migrations give it: for example, +``regions.region_type`` is the PostgreSQL enum ``v2_region_type``, which has +no comparison with text values, and ``parameter_values.value_json`` is +``jsonb``. Rule sim.persistence.public-api-copies-match checks this against +the Public API's own migrations (``test_public_api_schema``); a column or +table added here is checked with no other change. +""" + +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 policyengine_simulation_persistence.catalog_reads import CATALOG_READ_TABLES +from policyengine_simulation_persistence.schema import RUNTIME_ROLE +from sqlalchemy import create_engine, text + +if TYPE_CHECKING: + from sqlalchemy import Engine + +# The values and order of the Public API's baseline migration. +REGION_TYPES = ( + "national", + "country", + "state", + "congressional_district", + "constituency", + "local_authority", + "city", + "place", +) +PUBLIC_API_TABLE_STATEMENTS = ( + "CREATE TYPE public.v2_region_type AS ENUM (" + + ", ".join(f"'{region_type}'" for region_type in REGION_TYPES) + + ")", + "CREATE TABLE public.tax_benefit_models (" + " id uuid CONSTRAINT pk_tax_benefit_models PRIMARY KEY," + " name varchar(32) NOT NULL," + " CONSTRAINT uq_tax_benefit_models_name UNIQUE (name))", + "CREATE TABLE public.tax_benefit_model_versions (" + " id uuid CONSTRAINT pk_tax_benefit_model_versions 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 CONSTRAINT pk_datasets 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 CONSTRAINT pk_regions 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," + " label varchar(255) NOT NULL," + " region_type public.v2_region_type NOT NULL," + " requires_filter boolean NOT NULL DEFAULT false," + " filter_field varchar(128)," + " filter_value varchar(255)," + " filter_strategy varchar(64)," + " state_code varchar(16)," + " CONSTRAINT uq_regions_model_version_code" + " UNIQUE (tax_benefit_model_version_id, code))", + "CREATE TABLE public.policies (" + " id uuid CONSTRAINT pk_policies PRIMARY KEY," + " country_id varchar(2) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " CONSTRAINT uq_policies_id_country UNIQUE (id, country_id))", + "CREATE TABLE public.households (" + " id uuid CONSTRAINT pk_households PRIMARY KEY," + " country_id varchar(2) NOT NULL," + " CONSTRAINT uq_households_id_country UNIQUE (id, country_id))", + "CREATE TABLE public.dynamics (id uuid CONSTRAINT pk_dynamics PRIMARY KEY)", + "CREATE TABLE public.parameters (" + " id uuid CONSTRAINT pk_parameters 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 CONSTRAINT pk_parameter_values 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))", +) +REFERENCED_TABLES = ( + "tax_benefit_model_versions", + "datasets", + "regions", + "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. + + The runtime role may read the catalog and policy tables a v2 economy run + resolves its region, dataset and policy from, as the operator grants in + production (``CATALOG_READ_TABLES``). + """ + 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 CATALOG_READ_TABLES) + + f" TO {RUNTIME_ROLE}" + ) + statements = ( + (*PUBLIC_API_TABLE_STATEMENTS, grant, read_grant) + if grant_references + else (*PUBLIC_API_TABLE_STATEMENTS, read_grant) + ) + 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 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 + model_id: UUID + + +def _catalog_statements(rows: PublicApiRows) -> tuple[tuple[str, dict], ...]: + return ( + ( + "INSERT INTO public.tax_benefit_models (id, name) " + "VALUES (:model, 'policyengine-us')", + {"model": rows.model_id}, + ), + ( + "INSERT INTO public.tax_benefit_model_versions (id, model_id, version) " + "VALUES (:version, :model, '6.5.0'), (:newer_version, :model, '6.7.5')", + { + "model": rows.model_id, + "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, label, region_type) VALUES " + "(gen_random_uuid(), 'state/ca', :version, :dataset, 'California', " + "'state'), " + "(gen_random_uuid(), 'state/ca', :newer_version, :newer_dataset, " + "'California', 'state')", + { + "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: + """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(), + catalog_version_id=uuid4(), + newer_catalog_version_id=uuid4(), + dataset_id=uuid4(), + newer_dataset_id=uuid4(), + model_id=uuid4(), + ) + statements = ( + *_catalog_statements(rows), + ( + "INSERT INTO public.policies (id, country_id, " + "tax_benefit_model_version_id) VALUES " + "(:us_policy, 'us', :version), (:uk_policy, 'uk', :version)", + { + "us_policy": rows.us_policy_id, + "uk_policy": rows.uk_policy_id, + "version": rows.catalog_version_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/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/result_support.py b/libs/policyengine-simulation-persistence/tests/result_support.py new file mode 100644 index 000000000..49eba726e --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/result_support.py @@ -0,0 +1,109 @@ +"""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( + 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/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/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_catalog_reads.py b/libs/policyengine-simulation-persistence/tests/test_catalog_reads.py new file mode 100644 index 000000000..5543ec111 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_catalog_reads.py @@ -0,0 +1,222 @@ +"""The Public API's catalog and policy rows a v2 economy run is resolved from.""" + +from __future__ import annotations + +import pytest +from catalog_support import ( + CTC_AMOUNT, + ENGLAND_REGIONS, + INCOME_ELASTICITY, + RETIRED_PARAMETER, + US_STATES, + CatalogRows, + insert_catalog_rows, +) +from policyengine_simulation_persistence import ( + CatalogError, + CatalogReader, + CatalogVersion, + EconomyResultLabels, + GeographyLabel, + PolicyParametersMissingError, + RegionFilter, + create_simulation_engine, +) +from public_api_support import superuser_engine +from sqlalchemy import text + +pytestmark = pytest.mark.postgres + + +@pytest.fixture +def catalog_rows(migrated_database) -> CatalogRows: + return insert_catalog_rows(migrated_database.superuser_url) + + +@pytest.fixture +def reader(migrated_database) -> CatalogReader: + """Read as the runtime role, which the operator grants SELECT on these.""" + return CatalogReader(create_simulation_engine(migrated_database.runtime_url)) + + +def _us(rows: CatalogRows) -> CatalogVersion: + return CatalogVersion( + id=rows.us_version_id, country="us", policyengine_version="6.7.5" + ) + + +def test_a_catalog_version_is_found_by_country_and_policyengine_version( + reader, catalog_rows +) -> None: + assert reader.catalog_version("us", "6.7.5") == _us(catalog_rows) + assert reader.catalog_version("uk", "6.7.5") == CatalogVersion( + id=catalog_rows.uk_version_id, country="uk", policyengine_version="6.7.5" + ) + assert reader.catalog_version("us", "6.8.0") is None + assert reader.catalog_version_by_id(catalog_rows.older_us_version_id) == ( + CatalogVersion( + id=catalog_rows.older_us_version_id, + country="us", + policyengine_version="6.5.0", + ) + ) + + +def test_a_state_uses_its_default_dataset_and_its_row_filter( + reader, catalog_rows +) -> None: + population = reader.population(_us(catalog_rows), "state/ca", None) + + assert population.region_code == "state/ca" + assert population.dataset_id == catalog_rows.us_state_dataset_id + assert population.dataset_name == "populace_us_2024_acs_local" + assert population.row_filter == RegionFilter(field="state_fips", value="6") + assert population.state_code == "CA" + assert population.state_filters is None + + +def test_a_district_names_the_state_it_lies_in(reader, catalog_rows) -> None: + population = reader.population( + _us(catalog_rows), "congressional_district/CA-01", None + ) + + assert population.row_filter == RegionFilter( + field="congressional_district_geoid", value="601" + ) + assert population.state_code == "CA" + + +def test_a_simulation_can_name_another_dataset_of_the_version( + reader, catalog_rows +) -> None: + population = reader.population(_us(catalog_rows), "state/ca", "populace_us_2024") + + assert population.dataset_id == catalog_rows.us_national_dataset_id + assert population.dataset_name == "populace_us_2024" + + +def test_the_us_has_no_filter_and_each_states_filter_for_its_segments( + reader, catalog_rows +) -> None: + population = reader.population(_us(catalog_rows), "us", None) + + assert population.row_filter is None + assert population.dataset_name == "populace_us_2024" + assert population.state_filters == { + code: RegionFilter(field="state_fips", value=fips) + for code, (fips, _state) in US_STATES.items() + } + + +def test_a_uk_nation_keeps_the_list_of_regions_it_covers(reader, catalog_rows) -> None: + uk = reader.catalog_version("uk", "6.7.5") + assert uk is not None + + england = reader.population(uk, "country/england", None) + national = reader.population(uk, "uk", None) + + assert england.row_filter == RegionFilter(field="region", value=ENGLAND_REGIONS) + assert national.state_filters is None + assert national.row_filter is None + + +@pytest.mark.parametrize( + ("region_code", "dataset_name", "message"), + [ + ("state/zz", None, "no region state/zz"), + ("state/ca", "populace_us_2019", "no dataset populace_us_2019"), + ("place/CA-57000", None, "weight_replacement"), + ("place/CA-44000", None, "no household filter"), + ], +) +def test_a_region_or_dataset_the_version_cannot_run_is_refused( + reader, catalog_rows, region_code, dataset_name, message +) -> None: + with pytest.raises(CatalogError, match=message): + reader.population(_us(catalog_rows), region_code, dataset_name) + + +def test_a_policy_saved_on_an_older_version_runs_by_parameter_name( + reader, catalog_rows +) -> None: + changes = reader.parameter_changes( + catalog_rows.us_version_id, policy_id=catalog_rows.policy_id, dynamic_id=None + ) + + assert changes == { + CTC_AMOUNT: {"2025-01-01.2025-12-31": 2500, "2026-01-01": 3000}, + } + + +def test_current_law_changes_no_parameter(reader, catalog_rows) -> None: + assert ( + reader.parameter_changes( + catalog_rows.us_version_id, policy_id=None, dynamic_id=None + ) + == {} + ) + + +def test_a_dynamics_values_are_added_to_its_policys(reader, catalog_rows) -> None: + changes = reader.parameter_changes( + catalog_rows.us_version_id, + policy_id=catalog_rows.policy_id, + dynamic_id=catalog_rows.dynamic_id, + ) + + assert changes[INCOME_ELASTICITY] == {"2026-01-01": 0.1} + assert changes[CTC_AMOUNT]["2026-01-01"] == 3000 + + +def test_a_policy_changing_a_parameter_the_version_lacks_is_refused( + reader, catalog_rows +) -> None: + with pytest.raises(PolicyParametersMissingError) as raised: + reader.parameter_changes( + catalog_rows.us_version_id, + policy_id=catalog_rows.retired_policy_id, + dynamic_id=None, + ) + + assert raised.value.names == (RETIRED_PARAMETER,) + assert RETIRED_PARAMETER in str(raised.value) + + +def test_uk_area_results_take_their_names_from_the_catalog( + reader, catalog_rows +) -> None: + labels = reader.area_labels(catalog_rows.uk_version_id) + + assert labels == EconomyResultLabels( + constituencies={"E14001063": GeographyLabel(name="Aldershot", x=None, y=None)}, + local_authorities={ + "E06000063": GeographyLabel(name="Cumberland", x=None, y=None) + }, + ) + + +def test_period_keys_are_utc_dates_whatever_the_sessions_time_zone( + migrated_database, catalog_rows +) -> None: + engine = superuser_engine( + migrated_database.superuser_url, isolation_level="AUTOCOMMIT" + ) + try: + with engine.connect() as connection: + connection.execute( + text( + f'ALTER DATABASE "{migrated_database.name}" ' + "SET timezone TO 'America/Los_Angeles'" + ) + ) + finally: + engine.dispose() + reader = CatalogReader(create_simulation_engine(migrated_database.runtime_url)) + + changes = reader.parameter_changes( + catalog_rows.us_version_id, policy_id=catalog_rows.policy_id, dynamic_id=None + ) + + assert changes == { + CTC_AMOUNT: {"2025-01-01.2025-12-31": 2500, "2026-01-01": 3000}, + } 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..d15f35b35 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_definitions.py @@ -0,0 +1,100 @@ +"""New simulation and report definitions are checked before any write.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + NewReport, + NewSimulation, + ReportKind, + 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) + + +@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] + ) + + +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, + ) 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..700042ab6 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_economy_results.py @@ -0,0 +1,383 @@ +"""Economy results stored as rows and rebuilt exactly.""" + +from __future__ import annotations + +import pytest +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, + labels_for, + load_sample, + result_stores, +) +from run_support import economy_run +from simulation_support import economy_simulation, simulation_store +from sqlalchemy import text +from sqlalchemy.exc import ProgrammingError + +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_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", + "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_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}), + "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) + _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 _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 _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: + 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(), + ) + + +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( + 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()))} + ) + other_categories = result.intra_decile.model_copy( + update={"all": dict(reversed(result.intra_decile.all.items()))} + ) + mixed = result.constituency_impact.model_copy( + update={ + "root": [ + *result.constituency_impact.root, + result.local_authority_impact.root[0], + ] + } + ) + 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=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) + + +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_economy_run_reads.py b/libs/policyengine-simulation-persistence/tests/test_economy_run_reads.py new file mode 100644 index 000000000..a7a51e6a3 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_economy_run_reads.py @@ -0,0 +1,215 @@ +"""The reads and lookups the v2 economy path needs from the run tables.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + NewReportRun, + OutputFile, + RunErrorCode, + RunTrigger, + SimulationKind, + create_simulation_engine, + record_output_files, +) +from report_support import economy_pair, economy_report, report_store +from run_support import economy_run, run_store, set_simulation_run_status +from simulation_support import economy_simulation, simulation_store + +pytestmark = pytest.mark.postgres + +SHA256 = "a" * 64 + + +def _report_run(database, rows, *, reform_policy=True): + baseline, reform = economy_pair(database, rows) + if not reform_policy: + reform = baseline + 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 + 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, + ) + ) + return report, baseline_run, reform_run, report_run + + +def test_an_economy_simulation_with_the_same_definition_is_found_not_created( + migrated_database, public_api_rows +) -> None: + store = simulation_store(migrated_database) + first = store.find_or_create(economy_simulation(public_api_rows, policy_id=None)) + + again = store.find_or_create(economy_simulation(public_api_rows, policy_id=None)) + other = store.find_or_create(economy_simulation(public_api_rows)) + on_another_dataset = store.find_or_create( + economy_simulation( + public_api_rows, policy_id=None, dataset_name="populace_us_2024" + ) + ) + + assert again == first + assert other.id != first.id + assert on_another_dataset.id != first.id + assert ( + store.find_or_create( + economy_simulation( + public_api_rows, policy_id=None, dataset_name="populace_us_2024" + ) + ) + == on_another_dataset + ) + + +def test_a_reports_runs_are_listed_newest_first( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run, first = _report_run( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + second = 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 [run.id for run in store.report_runs(report.id)] == [second.id, first.id] + assert store.report_runs(uuid4()) == [] + + +def test_a_comparison_is_ready_once_both_its_runs_succeeded( + migrated_database, public_api_rows +) -> None: + _, baseline_run, reform_run, report_run = _report_run( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + assert store.comparisons_ready_to_start() == [] + + set_simulation_run_status(migrated_database, baseline_run.id, "succeeded") + assert store.comparisons_ready_to_start() == [] + set_simulation_run_status(migrated_database, reform_run.id, "succeeded") + + assert [run.id for run in store.comparisons_ready_to_start()] == [report_run.id] + assert [ + run.id + for run in store.comparisons_ready_to_start(simulation_run_ids={reform_run.id}) + ] == [report_run.id] + assert store.comparisons_ready_to_start(simulation_run_ids={uuid4()}) == [] + + +def test_a_run_compared_with_itself_is_ready_once_it_succeeded( + migrated_database, public_api_rows +) -> None: + _, baseline_run, reform_run, report_run = _report_run( + migrated_database, public_api_rows, reform_policy=False + ) + assert baseline_run.id == reform_run.id + + set_simulation_run_status(migrated_database, baseline_run.id, "succeeded") + + assert [ + run.id for run in run_store(migrated_database).comparisons_ready_to_start() + ] == [report_run.id] + + +def test_pending_runs_left_undispatched_are_found_after_a_while( + migrated_database, public_api_rows +) -> None: + _, baseline_run, reform_run, _ = _report_run(migrated_database, public_api_rows) + store = run_store(migrated_database) + set_simulation_run_status(migrated_database, reform_run.id, "failed") + + later = datetime.now(UTC) + timedelta(minutes=11) + assert [run.id for run in store.pending_simulation_runs(created_before=later)] == [ + baseline_run.id + ] + assert ( + store.pending_simulation_runs( + created_before=datetime.now(UTC) - timedelta(minutes=1) + ) + == [] + ) + + +def test_a_simulation_runs_stored_files_are_read_by_role( + migrated_database, public_api_rows +) -> None: + _, baseline_run, _, _ = _report_run(migrated_database, public_api_rows) + store = run_store(migrated_database) + written_at = datetime.now(UTC) + engine = create_simulation_engine(migrated_database.runtime_url) + with engine.begin() as connection: + record_output_files( + connection, + [ + OutputFile("simulation_output", "gs://bucket/output.parquet", SHA256), + OutputFile("simulation_input", "gs://bucket/input.json", "b" * 64), + ], + written_at=written_at, + simulation_run_id=baseline_run.id, + ) + + output = store.simulation_output_file(baseline_run.id, "simulation_output") + + assert output is not None + assert (output.uri, output.sha256) == ("gs://bucket/output.parquet", SHA256) + assert store.simulation_output_file(baseline_run.id, "simulation_log") is None + assert ( + store.simulation_output_file( + baseline_run.id, + "simulation_output", + now=written_at + timedelta(days=31), + ) + is None + ) + + +def test_a_run_the_catalog_cannot_run_fails_as_invalid_input() -> None: + assert RunErrorCode.INVALID_INPUT.value == "invalid_input" + + +def test_the_report_runs_comparing_a_simulation_run_are_found( + migrated_database, public_api_rows +) -> None: + _, baseline_run, reform_run, report_run = _report_run( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + + assert [run.id for run in store.report_runs_using(reform_run.id)] == [report_run.id] + assert [run.id for run in store.report_runs_using(baseline_run.id)] == [ + report_run.id + ] + assert store.report_runs_using(uuid4()) == [] + + +def test_pending_runs_can_be_narrowed_to_one_kind( + migrated_database, public_api_rows +) -> None: + _, baseline_run, _, _ = _report_run(migrated_database, public_api_rows) + store = run_store(migrated_database) + later = datetime.now(UTC) + timedelta(minutes=11) + + economy = store.pending_simulation_runs( + created_before=later, simulation_kind=SimulationKind.ECONOMY + ) + household = store.pending_simulation_runs( + created_before=later, simulation_kind=SimulationKind.HOUSEHOLD + ) + + assert baseline_run.id in {run.id for run in economy} + assert household == [] 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/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..b889e61a0 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_migration_cycle.py @@ -0,0 +1,268 @@ +"""Run the simulation migration chain against a fresh PostgreSQL database.""" + +from __future__ import annotations + +import re +import time + +import pytest +from alembic import command +from migration_support import ( + MIGRATOR_ROLE, + REPOSITORY_ROOT, + alembic_config, + applied_revision, + chain_config, + migration_cycle_failures, +) +from policyengine_simulation_persistence import ( + 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 + + +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() + + assert migration_cycle_failures(config, check=True) == [] + assert applied_revision(disposable_database.migrator_url) == HEAD_REVISION + + command.downgrade(config, "base") + assert applied_revision(disposable_database.migrator_url) is None + + +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: (psycopg.errors.DuplicateTable) relation " + '"proof_bad_table" already exists' + ] + + +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 applied_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 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( + disposable_database, +) -> None: + command.upgrade(alembic_config(), "head") + + assert applied_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() + + +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 + + +@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", "reports"]) +def test_the_runtime_role_cannot_change_or_delete_a_definition( + migrated_database, public_api_rows, table_name +) -> None: + 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 ( + 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() + + +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/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..e7b09e217 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_migration_script.py @@ -0,0 +1,54 @@ +"""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, + applied_revision, +) +from policyengine_simulation_persistence import ( + HEAD_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 test_verify_mode_refuses_a_database_behind_the_chain(disposable_database) -> None: + result = _run_script(disposable_database, "verify") + + assert result.returncode != 0 + 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 applied_revision(disposable_database.migrator_url) == 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/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/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..eb66ec159 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_ownership_boundaries.py @@ -0,0 +1,178 @@ +"""Simulation schema ownership: one chain, no executor dependency, none in Modal.""" + +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, +) + +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", +) + + +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_receives_no_database_url_but_the_comparison_paths() -> None: + assert ( + modal_credential_violations( + _texts(WORKFLOWS, "*.yml"), + _texts(WORKFLOW_SCRIPTS, "*.sh"), + ) + == [] + ) + for project_root in MODAL_PROJECTS: + 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: + """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, + ) + + +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 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", + "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 +) -> 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"] + 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/libs/policyengine-simulation-persistence/tests/test_public_api_schema.py b/libs/policyengine-simulation-persistence/tests/test_public_api_schema.py new file mode 100644 index 000000000..f1e6d8f04 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_public_api_schema.py @@ -0,0 +1,210 @@ +"""Sim's test copies of the Public API's tables match the Public API's own schema. + +Rule sim.persistence.public-api-copies-match. ``PUBLIC_API_SCHEMA_DATABASE_URL`` +names a database the Public API's v2 migration chain built (the CI job +``public-api-schema`` builds it from the Public API's default branch). The +copies are the tables ``public_api_support`` creates in a disposable +database. Without the variable the comparison skips, unless +``SIMULATION_REQUIRE_PUBLIC_API_SCHEMA=1`` (set in that job), where it fails +instead. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from public_api_schema import ( + ColumnShape, + SchemaShape, + TableShape, + describe_tables, + public_tables, + schema_differences, + schema_from_json, +) +from public_api_support import superuser_engine + +pytestmark = pytest.mark.public_api_schema + +REFERENCE_DATABASE_VARIABLE = "PUBLIC_API_SCHEMA_DATABASE_URL" +REQUIRE_REFERENCE_VARIABLE = "SIMULATION_REQUIRE_PUBLIC_API_SCHEMA" +# The reference script sets it on GitHub Actions: the Public API commit migrated. +COMMIT_VARIABLE = "PUBLIC_API_SCHEMA_COMMIT" +PROOF_FIXTURES = ( + Path(__file__).resolve().parents[3] + / "rules" + / "fixtures" + / "sim.persistence.public-api-copies-match" +) + + +@pytest.fixture +def public_api_schema_url() -> str: + """Return the URL of the database the Public API's migrations built.""" + configured = os.environ.get(REFERENCE_DATABASE_VARIABLE, "") + if not configured: + message = f"{REFERENCE_DATABASE_VARIABLE} names no Public API database" + if os.environ.get(REQUIRE_REFERENCE_VARIABLE) == "1": + pytest.fail(message) + pytest.skip(message) + return configured + + +def test_the_copies_of_the_public_api_tables_match_its_schema( + disposable_database, public_api_schema_url +) -> None: + copies_engine = superuser_engine(disposable_database.superuser_url) + real_engine = superuser_engine(public_api_schema_url) + try: + with copies_engine.connect() as connection: + copies = describe_tables(connection, public_tables(connection)) + with real_engine.connect() as connection: + real = describe_tables(connection, copies.tables) + finally: + copies_engine.dispose() + real_engine.dispose() + + assert copies.tables + compared_with = os.environ.get(COMMIT_VARIABLE) or "an unrecorded commit" + assert schema_differences(copies, real) == [], ( + f"compared with the Public API at {compared_with}" + ) + + +def test_rule_proof_public_api_copies_match() -> None: + """Proof for rule sim.persistence.public-api-copies-match. + + ``public_api_schema.json`` describes the Public API's tables as its v2 + migration chain builds them. The bad copies are the good ones with the + mistake an earlier review found: ``regions.region_type`` copied as + ``varchar(64)``, where the Public API's column is the enum + ``v2_region_type``. + """ + real = schema_from_json(PROOF_FIXTURES / "public_api_schema.json") + bad = schema_from_json(PROOF_FIXTURES / "bad_copies.json") + good = schema_from_json(PROOF_FIXTURES / "good_copies.json") + + assert schema_differences(bad, real) == [ + "regions.region_type: the copy is character varying(64), the Public " + "API's is enum v2_region_type", + ] + assert schema_differences(good, real) == [] + + +def _regions(**changes) -> SchemaShape: + """Return a regions table with an enum column, changed as given.""" + columns = { + "id": ColumnShape("uuid", nullable=False), + "code": ColumnShape("character varying(255)", nullable=False), + "region_type": ColumnShape("enum v2_region_type", nullable=False), + "state_code": ColumnShape("character varying(16)", nullable=True), + } + columns.update(changes.pop("columns", {})) + constraints = { + "pk_regions": ("id",), + "uq_regions_model_version_code": ("tax_benefit_model_version_id", "code"), + } + constraints.update(changes.pop("constraints", {})) + enums = changes.pop("enums", {"v2_region_type": ("national", "state")}) + return SchemaShape( + tables={"regions": TableShape(columns=columns, unique_constraints=constraints)}, + enums=enums, + ) + + +@pytest.mark.parametrize( + ("copies", "real", "difference"), + [ + ( + _regions( + columns={ + "state_code": ColumnShape("character varying(16)", nullable=False) + } + ), + _regions(), + "regions.state_code: the copy refuses null, the Public API's allows it", + ), + ( + _regions(columns={"parent_code": ColumnShape("text", nullable=True)}), + _regions(), + "regions.parent_code: the Public API's table has no such column", + ), + ( + _regions(constraints={"uq_regions_code": ("code",)}), + _regions(), + "regions: the Public API's table has no constraint uq_regions_code (code)", + ), + ( + _regions( + constraints={ + "uq_regions_model_version_code": ( + "code", + "tax_benefit_model_version_id", + ) + } + ), + _regions(), + "regions: uq_regions_model_version_code is (code, " + "tax_benefit_model_version_id) in the copy, " + "(tax_benefit_model_version_id, code) in the Public API", + ), + ( + _regions(), + _regions(enums={"v2_region_type": ("national", "state", "city")}), + "enum v2_region_type: the copy has (national, state), the Public API " + "has (national, state, city)", + ), + ( + _regions(), + _regions(enums={}), + "enum v2_region_type: the Public API has no such enum in its public schema", + ), + ( + _regions(), + SchemaShape(tables={}, enums=_regions().enums), + "regions: the Public API has no such table", + ), + ], + ids=[ + "nullability", + "missing-column", + "missing-constraint", + "reordered-constraint", + "enum-values", + "missing-enum", + "missing-table", + ], +) +def test_each_difference_is_reported(copies, real, difference) -> None: + assert schema_differences(copies, real) == [difference] + + +def test_a_json_copy_of_a_jsonb_column_is_refused() -> None: + """``parameter_values.value_json`` is jsonb in the Public API. + + Its baseline revision created it as json, and a later revision made it + jsonb, which compares and orders object keys differently; a copy that + kept json would test reads against the wrong type. + """ + real = schema_from_json(PROOF_FIXTURES / "public_api_schema.json") + good = schema_from_json(PROOF_FIXTURES / "good_copies.json") + parameter_values = good.tables["parameter_values"] + json_copy = SchemaShape( + tables={ + **good.tables, + "parameter_values": TableShape( + columns={ + **parameter_values.columns, + "value_json": ColumnShape("json", nullable=False), + }, + unique_constraints=parameter_values.unique_constraints, + ), + }, + enums=good.enums, + ) + + assert schema_differences(json_copy, real) == [ + "parameter_values.value_json: the copy is json, the Public API's is jsonb", + ] diff --git a/libs/policyengine-simulation-persistence/tests/test_public_api_schema_job.py b/libs/policyengine-simulation-persistence/tests/test_public_api_schema_job.py new file mode 100644 index 000000000..7c02e53e1 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_public_api_schema_job.py @@ -0,0 +1,176 @@ +"""The CI job that builds the Public API's tables and compares sim's copies. + +Rule sim.persistence.public-api-copies-match. The ``public-api-schema`` job +in ``pr.yml`` checks out the Public API's default branch, builds its tables +with its own v2 migration chain +(``.github/scripts/public-api-schema-reference.sh``), and runs the +comparison in ``test_public_api_schema``. It reads no secret. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import yaml + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +PR_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "pr.yml" +REFERENCE_SCRIPT = ( + REPOSITORY_ROOT / ".github" / "scripts" / "public-api-schema-reference.sh" +) +JOB = "public-api-schema" +REFERENCE_URL = ( + "postgresql://postgres:simulation_ci@127.0.0.1:5432/policyengine_v2_alembic_test" +) + + +def _job() -> dict: + workflow = yaml.safe_load(PR_WORKFLOW.read_text(encoding="utf-8")) + return workflow["jobs"][JOB] + + +def _step(job: dict, name: str) -> dict: + return next(step for step in job["steps"] if step.get("name") == name) + + +def test_the_job_compares_with_the_public_apis_default_branch() -> None: + job = _job() + checkout = _step(job, "Check out the Public API's default branch") + + assert checkout["uses"].startswith("actions/checkout@") + assert checkout["with"] == { + "repository": "PolicyEngine/policyengine-api", + "path": "public-api", + "persist-credentials": False, + } + assert _step(job, "Build the Public API's tables from its v2 migration chain")[ + "run" + ] == (".github/scripts/public-api-schema-reference.sh public-api") + compare = _step(job, "Compare the test copies with the Public API's tables") + assert compare["working-directory"] == "libs/policyengine-simulation-persistence" + assert compare["run"] == "uv run --frozen pytest tests/ -v -m public_api_schema" + + +def test_the_job_needs_no_secret_and_fails_instead_of_skipping() -> None: + job = _job() + + assert "secrets" not in yaml.safe_dump(job) + assert job["permissions"] == {"contents": "read"} + assert job["env"]["PUBLIC_API_SCHEMA_DATABASE_URL"] == REFERENCE_URL + assert job["env"]["SIMULATION_REQUIRE_PUBLIC_API_SCHEMA"] == "1" + assert job["env"]["SIMULATION_REQUIRE_DATABASE_TESTS"] == "1" + # The Public API's disposable migration mode accepts only this database. + assert ( + job["services"]["postgres"]["env"]["POSTGRES_DB"] + == "policyengine_v2_alembic_test" + ) + + +def _fake_commands( + tmp_path: Path, *, failing_sync: bool, failing_git: bool = False +) -> tuple[dict, Path]: + bin_directory = tmp_path / "bin" + bin_directory.mkdir() + calls = tmp_path / "calls.txt" + sync_exit = "1" if failing_sync else "0" + (bin_directory / "uv").write_text( + "#!/usr/bin/env bash\n" + f'echo "uv $* | UV_PYTHON=${{UV_PYTHON:-}} ' + "V2_MIGRATION_DATABASE_URL=${V2_MIGRATION_DATABASE_URL:-} " + f'V2_ALEMBIC_DISPOSABLE_TEST=${{V2_ALEMBIC_DISPOSABLE_TEST:-}}" >> {calls}\n' + f'if [ "$1" = sync ]; then exit {sync_exit}; fi\n', + encoding="utf-8", + ) + (bin_directory / "git").write_text( + "#!/usr/bin/env bash\n" + + ( + "echo 'fatal: not a git repository' >&2\nexit 128\n" + if failing_git + else "echo 0123456789abcdef\n" + ), + encoding="utf-8", + ) + for command in bin_directory.iterdir(): + command.chmod(0o755) + environment = { + "PATH": f"{bin_directory}{os.pathsep}{os.environ['PATH']}", + "PUBLIC_API_SCHEMA_DATABASE_URL": REFERENCE_URL, + } + return environment, calls + + +def _run_script(environment: dict, checkout: Path) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", str(REFERENCE_SCRIPT), str(checkout)], + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +def test_the_script_migrates_the_database_with_the_public_apis_chain( + tmp_path: Path, +) -> None: + environment, calls = _fake_commands(tmp_path, failing_sync=False) + checkout = tmp_path / "public-api" + checkout.mkdir() + + github_environment = tmp_path / "github_env" + environment["GITHUB_ENV"] = str(github_environment) + + result = _run_script(environment, checkout) + + assert result.returncode == 0, result.stderr + assert "Public API commit: 0123456789abcdef" in result.stdout + assert github_environment.read_text(encoding="utf-8") == ( + "PUBLIC_API_SCHEMA_COMMIT=0123456789abcdef\n" + ) + migration_url = REFERENCE_URL.replace("postgresql://", "postgresql+psycopg://") + assert calls.read_text(encoding="utf-8").splitlines() == [ + "uv sync --frozen --extra dev | UV_PYTHON=3.12 " + "V2_MIGRATION_DATABASE_URL= V2_ALEMBIC_DISPOSABLE_TEST=", + "uv run --frozen --extra dev alembic -c alembic-v2.ini upgrade head | " + f"UV_PYTHON=3.12 V2_MIGRATION_DATABASE_URL={migration_url} " + "V2_ALEMBIC_DISPOSABLE_TEST=1", + ] + + +def test_the_script_stops_when_the_public_api_cannot_install(tmp_path: Path) -> None: + environment, calls = _fake_commands(tmp_path, failing_sync=True) + checkout = tmp_path / "public-api" + checkout.mkdir() + + result = _run_script(environment, checkout) + + assert result.returncode != 0 + assert len(calls.read_text(encoding="utf-8").splitlines()) == 1 + + +def test_the_script_needs_the_database_to_migrate(tmp_path: Path) -> None: + environment, calls = _fake_commands(tmp_path, failing_sync=False) + del environment["PUBLIC_API_SCHEMA_DATABASE_URL"] + checkout = tmp_path / "public-api" + checkout.mkdir() + + result = _run_script(environment, checkout) + + assert result.returncode != 0 + assert "PUBLIC_API_SCHEMA_DATABASE_URL" in result.stderr + assert not calls.exists() + + +def test_the_script_stops_when_it_cannot_name_the_commit_it_checks( + tmp_path: Path, +) -> None: + """A failed comparison must say which Public API commit it compared with.""" + environment, calls = _fake_commands(tmp_path, failing_sync=False, failing_git=True) + checkout = tmp_path / "public-api" + checkout.mkdir() + + result = _run_script(environment, checkout) + + assert result.returncode != 0 + assert not calls.exists() 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..28b096c68 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_recording_load.py @@ -0,0 +1,223 @@ +"""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 + +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 + +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 _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) + 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_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_per_call + + +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] = [] + self.abandoned_runs: list[object] = [] + + 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() + 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) + and len(outcomes.abandoned_runs) == STALLED_DISPATCHES + ): + return passes + clock.advance(LEASE) + continue + for call in calls: + outcome = chooser.random() + if outcome < 0.3: + # 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( + 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() + runs_per_call = _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 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( + 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: 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: {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" + ) 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..d1061a8b7 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_reports.py @@ -0,0 +1,229 @@ +"""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, + create_simulation_engine, +) +from policyengine_simulation_persistence.reports import reports +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 insert, 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 + + +@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_result_completeness.py b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py new file mode 100644 index 000000000..89eadeea3 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_result_completeness.py @@ -0,0 +1,83 @@ +"""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, + 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. +WHOLE_VALUES = (SPMComparisonProvenance,) +CATALOG_SOURCES = { + "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 + + +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/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" + ) 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..e54fce729 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_run_transitions.py @@ -0,0 +1,546 @@ +"""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_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: + 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_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.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_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: + 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" + # 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" + ) + 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/libs/policyengine-simulation-persistence/tests/test_runs.py b/libs/policyengine-simulation-persistence/tests/test_runs.py new file mode 100644 index 000000000..e268a8fa8 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_runs.py @@ -0,0 +1,649 @@ +"""simulation_runs and report_runs: executions of simulations and reports.""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier +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 policyengine_simulation_persistence.run_store import FORCED_START_INSERT_LIMIT +from public_api_support import delete_public_api_row, superuser_engine +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 + + +LOCK_WAIT_TIMEOUT_SECONDS = 10 + + +def _wait_until_a_statement_waits_for_a_lock(database) -> None: + engine = superuser_engine(database.superuser_url) + deadline = time.monotonic() + LOCK_WAIT_TIMEOUT_SECONDS + try: + with engine.connect() as connection: + while time.monotonic() < deadline: + waiting = connection.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() " + "AND wait_event_type = 'Lock'" + ) + ).scalar_one() + connection.rollback() + if waiting: + return + time.sleep(0.05) + finally: + engine.dispose() + msg = "no statement waited for a lock" + raise AssertionError(msg) + + +def test_a_forced_start_that_loses_the_insert_adds_the_attempt_after( + migrated_database, public_api_rows +) -> None: + """A forced caller always starts a run of its own. + + Another caller adds the attempt the forced caller read as next, between + that read and the forced caller's insert. A forced start (a labour supply + report's) must not receive the other caller's run, which may have been + started without what the forced caller needs. + """ + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + other_caller_engine = superuser_engine(migrated_database.superuser_url) + try: + with other_caller_engine.connect() as other_caller: + other_transaction = other_caller.begin() + other_caller.execute( + text( + "INSERT INTO simulation.simulation_runs (id, simulation_id, " + "simulation_kind, tax_benefit_model_version_id, year, attempt, " + "region_code, dataset_id, trigger, status, created_at, " + "updated_at) SELECT gen_random_uuid(), simulation_id, " + "simulation_kind, tax_benefit_model_version_id, year, " + "attempt + 1, region_code, dataset_id, 'initial', 'pending', " + "now(), now() FROM simulation.simulation_runs WHERE id = :run_id" + ), + {"run_id": first.run.id}, + ) + with ThreadPoolExecutor(max_workers=1) as pool: + forced_start = pool.submit( + store.start_simulation_run, + economy_run(baseline, public_api_rows, trigger=RunTrigger.MANUAL), + force=True, + ) + _wait_until_a_statement_waits_for_a_lock(migrated_database) + other_transaction.commit() + forced = forced_start.result(timeout=LOCK_WAIT_TIMEOUT_SECONDS) + finally: + other_caller_engine.dispose() + + assert forced.created is True + assert forced.run.attempt == first.run.attempt + 2 + assert forced.run.trigger is RunTrigger.MANUAL + + +def test_a_forced_start_that_loses_every_insert_gives_up( + migrated_database, public_api_rows, monkeypatch +) -> None: + """A forced start raises after its limit of lost inserts. + + Each lost insert means another caller added an attempt, so the limit is + reached only by a runaway of simultaneous starts. + """ + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + store.start_simulation_run(economy_run(baseline, public_api_rows)) + attempted = [] + + def lose_every_insert(_connection, _new_run, _kind, attempt, _now): + attempted.append(attempt) + + monkeypatch.setattr(store, "_insert_attempt", lose_every_insert) + + with pytest.raises(RuntimeError, match="took every attempt"): + store.start_simulation_run(economy_run(baseline, public_api_rows), force=True) + assert len(attempted) == FORCED_START_INSERT_LIMIT + + +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_first_report_run_is_added_only_while_the_report_has_none( + 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) + first_open = NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.SYSTEM, + ) + + first = store.create_first_report_run(first_open) + again = store.create_first_report_run(first_open) + + assert first.created is True + assert first.run.trigger is RunTrigger.SYSTEM + assert again.created is False + assert again.run == first.run + assert store.report_runs(report.id) == [first.run] + + +SIMULTANEOUS_FIRST_OPENS = 6 + + +def test_simultaneous_first_report_runs_of_a_report_add_one( + migrated_database, public_api_rows +) -> None: + """Each caller reads that the report has no run before any inserts one.""" + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + first_open = NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.SYSTEM, + ) + all_ready = Barrier(SIMULTANEOUS_FIRST_OPENS) + + def open_first_time(_: int): + all_ready.wait() + return store.create_first_report_run(first_open) + + with ThreadPoolExecutor(max_workers=SIMULTANEOUS_FIRST_OPENS) as pool: + started = list(pool.map(open_first_time, range(SIMULTANEOUS_FIRST_OPENS))) + + assert sum(start.created for start in started) == 1 + assert {start.run.id for start in started} == {started[0].run.id} + assert len(store.report_runs(report.id)) == 1 + + +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 new file mode 100644 index 000000000..d116d72e0 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_schema.py @@ -0,0 +1,143 @@ +"""Schema names and connection settings, without a database.""" + +from __future__ import annotations + +import importlib +import pkgutil + +import policyengine_simulation_persistence +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, + 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 + + +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) + + +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", + "simulation.reports", + "simulation.simulation_runs", + "simulation.report_runs", + *( + 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/libs/policyengine-simulation-persistence/tests/test_simulations.py b/libs/policyengine-simulation-persistence/tests/test_simulations.py new file mode 100644 index 000000000..f574b8f26 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_simulations.py @@ -0,0 +1,247 @@ +"""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, + 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 + + +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 + + +@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() 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, + ) diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock new file mode 100644 index 000000000..b78f0fa75 --- /dev/null +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -0,0 +1,975 @@ +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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-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 = "pydantic" }, + { name = "sqlalchemy" }, +] + +[package.optional-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.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 = "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" }, + { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0.2" }, + { name = "sqlalchemy", specifier = ">=2,<3" }, +] +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" +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 = "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" +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 = "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 = "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" +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 = "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" +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 = "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" +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" }, +] + +[[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/alembic.ini b/migrations/alembic.ini new file mode 100644 index 000000000..66aaf1189 --- /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 --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 + +[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..b024e1d89 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +"""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, text +from sqlalchemy.schema import CreateSchema + +if TYPE_CHECKING: + from sqlalchemy import Connection + +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: + 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: + 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) + 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/migrations/versions/0002_simulations_and_reports.py b/migrations/versions/0002_simulations_and_reports.py new file mode 100644 index 000000000..b3fd790de --- /dev/null +++ b/migrations/versions/0002_simulations_and_reports.py @@ -0,0 +1,196 @@ +"""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. ``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 +Create Date: 2026-10-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +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=op.f("ck_simulations_kind"), + ), + sa.CheckConstraint( + "country ~ '^[a-z]{2}$'", name=op.f("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=op.f("ck_simulations_population_matches_kind"), + ), + sa.CheckConstraint( + "region_code IS NULL OR region_code ~ '^\\S+$'", + name=op.f("ck_simulations_region_code"), + ), + sa.CheckConstraint( + "dataset_name IS NULL OR dataset_name ~ '^\\S+$'", + name=op.f("ck_simulations_dataset_name"), + ), + sa.CheckConstraint( + "legacy_simulation_id IS NULL OR legacy_simulation_id > 0", + name=op.f("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 _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=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=op.f("ck_reports_economy_has_reform"), + ), + sa.CheckConstraint( + "legacy_report_id IS NULL OR legacy_report_id > 0", + name=op.f("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 both tables; the runtime role may not update either.""" + _create_simulations() + _create_reports() + op.execute( + "REVOKE UPDATE ON simulation.simulations, simulation.reports " + "FROM simulation_runtime" + ) + + +def downgrade() -> None: + """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 + ) + op.drop_table("simulations", schema=SCHEMA) 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..f7a2b5d43 --- /dev/null +++ b/migrations/versions/0003_simulation_runs_and_report_runs.py @@ -0,0 +1,336 @@ +"""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_pending_comparisons", + "report_runs", + ["id"], + schema=SCHEMA, + postgresql_where=sa.text("comparison_status = 'pending'"), + ) + 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_pending_comparisons", + "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" + ) diff --git a/migrations/versions/0004_result_tables.py b/migrations/versions/0004_result_tables.py new file mode 100644 index 000000000..12665d157 --- /dev/null +++ b/migrations/versions/0004_result_tables.py @@ -0,0 +1,579 @@ +"""Add the result tables. + +Every economy result is stored as rows, each section of today's result model +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, 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, +because a reused run's figures can differ between comparisons. 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 + +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 +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 _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("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"], + 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( + "report_run_id", "simulation_run_id", "side", 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("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"], + 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( + "report_run_id", "simulation_run_id", "side", 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("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), + 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.CheckConstraint( + "side IN ('baseline', 'reform')", name=op.f("ck_poverty_rates_side") + ), + 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"], + name="fk_poverty_rates_simulation_run", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "report_run_id", + "simulation_run_id", + "side", + "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.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") + ), + 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("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( + "(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"], + ["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", + "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", + ) + 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.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") + ), + 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", + ) + 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), + schema="simulation", + ) + op.add_column( + "report_runs", + sa.Column( + "spm_provenance", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + 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", + "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", "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") + 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") + op.drop_table("household_outputs", schema="simulation") + op.drop_table("output_files", schema="simulation") diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 9783ef1cc..ff78ae1bb 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -22,6 +22,165 @@ 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. 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` +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. 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`. + +## Recording run outcomes + +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. + +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. + +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 +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`, its plain telemetry settings and +its `APP_ENVIRONMENT` and `SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`, 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, 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). + +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. + +## v2 economy reports + +The entrypoint runs economy reports on v2 itself, without the old gateway: + +- `POST /v2/economy-reports` creates a report's two economy simulations (or + finds them, so their runs can be reused), the report and its first run; + `POST /v2/economy-reports/{report_id}/runs` runs it again. Each run of a + simulation has its own Modal call, `run_economy_simulation_us` or `_uk` in + the v2 app of the run's PolicyEngine.py version, and each report run's + comparison step another, `compare_economy_simulations`, which starts once + both runs have succeeded. Calls start in the Modal environment named by + `SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`; without it the routes answer + 503. +- `GET /v2/economy-reports/{report_id}`, `GET + /v2/economy-reports/by-legacy-id/{legacy_report_id}` and `GET + /v2/economy-reports/{report_id}/runs` read a report, its runs and, once + compared, its result rebuilt from its rows; `GET + /v2/simulations/{simulation_id}` reads a simulation. Submissions need + `simulation:submit` and reads `simulation:read`. +- A run's region, dataset and policy come from the Public API's catalog rows + of the run's catalog version, which the runtime role reads. The owner of + the Public API's tables must grant it `SELECT` on `tax_benefit_models`, + `tax_benefit_model_versions`, `regions`, `datasets`, `parameters` and + `parameter_values` in each environment (see + `libs/policyengine-simulation-persistence/README.md`). +- Each safety-check pass also starts the comparisons whose runs have + succeeded and the runs a submitter left pending for ten minutes. A + submission starts only the runs it created; a report that reuses a run + another submission created waits for that submission to start it. If that + submitter stopped before starting it, the run's calculation starts only + after those ten minutes, plus up to one five-minute interval of the + safety check's schedule. +- Opening a report migrated from v1 without a run starts its first run with + the `system` trigger. A `system` run returns the report's newest run when + it has one, so simultaneous first openings share one run. +- Each route declares its error answers in the OpenAPI document, so the + generated client parses them: 404 and 503 with a text `detail`, 422 with + sim's reason as text or FastAPI's list of refused fields, and, on the + report reads, 500 when a stored result cannot be rebuilt. +- After each staging promotion, the `economy_staging_check` job submits a + report on Wales, current law compared with itself, to the stable URL, runs + it again with fresh runs when the submission reused runs an earlier + deployment finished, and reads the result back + (`authenticated_tests/test_staging_economy_report.py`). It needs a staging + catalog version for the deployed PolicyEngine.py version. Its failure is + shown in the run without stopping the production deployment. + ## Candidate qualification For each eligible revision, the workflow first deploys `beta` @@ -33,17 +192,33 @@ 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, +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 -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. + +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 @@ -51,6 +226,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 +261,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 +296,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/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/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/authenticated_tests/test_staging_economy_report.py b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_economy_report.py new file mode 100644 index 000000000..21fbfe388 --- /dev/null +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_economy_report.py @@ -0,0 +1,149 @@ +"""A small v2 economy report on staging, submitted and read back once compared. + +The staging deployment runs this after promoting the revision it tested, so +it reaches that revision through the stable URL: it submits a report on +Wales, current law compared with itself, waits until a run this +deployment's Modal functions calculated has its comparison recorded, and +reads the result back. Current law compared with itself is one simulation +run, so a submission with a fresh run costs one simulation call and one +comparison call, about a minute or two of Modal time. When the submission +reused a run an earlier deployment finished, the submission compares that +run at once, and the check runs the report again with a fresh run: one +simulation call and two comparison calls. It needs the staging simulation +database, sim's Modal token, and a staging catalog version for the deployed +PolicyEngine.py version. + +The deployed-auth runs collect this directory too; the test runs only where +``SIMULATION_STAGING_ECONOMY_CHECK`` is 1. +""" + +from __future__ import annotations + +import os +import time + +import httpx +import pytest +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput + +COUNTRY = "uk" +REGION = "country/wales" +YEAR = 2026 +POLL_INTERVAL_SECONDS = 10 +# Both Modal calls, each from a cold start, with a margin. +FINISH_WITHIN_SECONDS = 20 * 60 +FINISHED = {"succeeded", "failed"} + +pytestmark = pytest.mark.skipif( + os.environ.get("SIMULATION_STAGING_ECONOMY_CHECK") != "1", + reason="runs only in the staging economy check", +) + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + msg = f"{name} is required for the staging economy check." + raise RuntimeError(msg) + return value + + +@pytest.fixture(scope="module") +def base_url() -> str: + """Return the staging stable URL, without a trailing slash.""" + return _required("SIMULATION_ENTRYPOINT_TEST_BASE_URL").rstrip("/") + + +@pytest.fixture(scope="module") +def headers() -> dict[str, str]: + """Return the test client's bearer token for the entrypoint's audience.""" + 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 needs_fresh_run(submitted: dict) -> bool: + """Return whether the submission reused runs an earlier deployment finished. + + Only a reused run can have succeeded when the submission answers. A run + the submission created is calculated by this deployment's functions; a + run an earlier deployment is still calculating is waited for as it is. + """ + return submitted["run"]["baseline_run"]["status"] == "succeeded" + + +def _wait_for_run(base_url: str, headers: dict[str, str], report_id: str, run_id: str): + deadline = time.monotonic() + FINISH_WITHIN_SECONDS + while True: + response = httpx.get( + f"{base_url}/v2/economy-reports/{report_id}", headers=headers, timeout=60 + ) + response.raise_for_status() + report = response.json() + run = report["run"] + if run is not None and run["id"] == run_id and run["status"] in FINISHED: + return report + if time.monotonic() > deadline: + pytest.fail( + f"report run {run_id} did not finish within " + f"{FINISH_WITHIN_SECONDS} s; it is {run and run['status']}" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + +def test_a_small_economy_report_runs_and_reads_back( + base_url: str, headers: dict[str, str] +) -> None: + """Submit, run again if needed, wait, and read the result back.""" + policyengine_version = _required("SIMULATION_STAGING_ECONOMY_POLICYENGINE_VERSION") + submitted = httpx.post( + f"{base_url}/v2/economy-reports", + json={ + "country": COUNTRY, + "policyengine_version": policyengine_version, + "year": YEAR, + "region_code": REGION, + "baseline": {}, + "reform": {}, + }, + headers=headers, + timeout=120, + ) + assert submitted.status_code == 201, submitted.text + report_id = submitted.json()["id"] + run_id = submitted.json()["run"]["id"] + if needs_fresh_run(submitted.json()): + rerun = httpx.post( + f"{base_url}/v2/economy-reports/{report_id}/runs", + json={ + "policyengine_version": policyengine_version, + "trigger": "manual", + "force": True, + }, + headers=headers, + timeout=120, + ) + assert rerun.status_code == 201, rerun.text + run_id = rerun.json()["id"] + + report = _wait_for_run(base_url, headers, report_id, run_id) + + assert report["status"] == "succeeded", report["run"]["error"] + result = SingleYearMacroOutput.model_validate(report["result"]) + # Current law compared with itself changes nothing. + assert result.budget.budgetary_impact == 0 + assert result.budget.baseline_net_income > 0 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..b0273eeb4 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 @@ -25,7 +26,6 @@ JobSubmitResponse, PingRequest, PingResponse, - ReadinessResponse, SimulationErrorResponse, SimulationRequest, VersionMap, @@ -42,6 +42,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 ( @@ -51,7 +52,17 @@ 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.economy_recorders import ComparisonStartingRecorder +from policyengine_simulation_entry.economy_routes import ( + EconomyRouteServices, + register_economy_routes, +) from policyengine_simulation_entry.entry_runtime import ( BACKEND_RESPONSE_HEADER, STAGE12_MODAL_SUBMISSION_TIMEOUT_SECONDS, @@ -59,15 +70,20 @@ ComparisonBackend, EntryRuntime, ResponseIdentifier, + SimulationDatabaseConnection, model_json, request_identifiers, route_template, set_observability_header, ) +from policyengine_simulation_entry.modal_identity import ModalIdentityVerifier 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 +100,29 @@ "STAGE12_MODAL_SUBMISSION_TIMEOUT_SECONDS", "ComparisonBackend", "ResponseIdentifier", + "RunServices", + "SimulationDatabaseConnection", "app", "create_app", ] logger = logging.getLogger(__name__) +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 + economy: EconomyRouteServices | None = None def _temporary_submission_payload( @@ -456,6 +490,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 +550,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 +586,35 @@ def create_app( backend: SimulationBackend | None = None, auth_dependency: AuthenticationDependency | None = None, comparison_backend: ComparisonBackend | 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() + 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() + economy = services.economy + if ( + economy is None + and isinstance(runtime_database, SimulationDatabase) + and runtime_settings.call_report_modal_environment + ): + economy = runtime_database.economy_routes( + artifact_environment=runtime_settings.environment, + modal_environment=runtime_settings.call_report_modal_environment, + ) + if call_recorder is not None and economy is not None: + call_recorder = ComparisonStartingRecorder(call_recorder, economy.reports) + 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]: @@ -527,6 +624,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 +664,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: runtime=runtime, backend=runtime_backend, comparison=runtime_comparison, + database=runtime_database, ) _install_request_context(app, runtime_settings) @@ -577,6 +677,15 @@ 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 + ) + register_economy_routes( + app, + services=economy, + submitter=simulation_submitter, + reader=simulation_reader, + ) 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..f21e56325 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/call_report_route.py @@ -0,0 +1,161 @@ +"""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 + + +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, + *, + 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) + identity = await _caller(request, verifier) + if isinstance(identity, JSONResponse): + return identity + 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/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 8385097e9..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): @@ -92,6 +98,14 @@ 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 = "" + # 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: @@ -142,6 +156,13 @@ 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", ""), + 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 @@ -149,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.""" @@ -170,6 +203,8 @@ def validate(self) -> None: self._validate_caller_auth() 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.""" @@ -267,3 +302,30 @@ 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) + + 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/economy_recorders.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_recorders.py new file mode 100644 index 000000000..6cc342628 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_recorders.py @@ -0,0 +1,145 @@ +"""How sim records a finished v2 economy call: its stored files, or its result. + +An economy simulation run's call returns the files it stored +(``EconomySimulationOutput``); sim links them to the run in the transaction +that marks it succeeded, and a later comparison reads the output file. A +report run's comparison call returns its ``SingleYearMacroOutput``, which the +persistence library's ``EconomyComparisonRecorder`` stores as rows in the +transaction that marks the comparison step succeeded. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_contract.call_reports import CallReportStatus +from policyengine_simulation_contract.economy_runs import EconomySimulationOutput +from policyengine_simulation_persistence import ( + EconomyComparisonRecorder, + OutputFile, + ReportKind, + SimulationKind, + record_output_files, +) +from policyengine_simulation_persistence.run_store import utc_now + +from policyengine_simulation_entry.run_recording import ResultRecorders + +if TYPE_CHECKING: + from collections.abc import Callable, Collection + from datetime import datetime + from uuid import UUID + + from policyengine_simulation_contract.call_reports import CallEndedReport + from policyengine_simulation_persistence import SimulationRunRecord + from sqlalchemy import Connection + +logger = logging.getLogger(__name__) + +# The roles of the files an economy simulation run stores. +SIMULATION_INPUT_ROLE = "simulation_input" +SIMULATION_OUTPUT_ROLE = "simulation_output" + + +@dataclass(frozen=True) +class EconomySimulationRecorder: + """Validate an economy simulation call's output and link its stored files.""" + + clock: Callable[[], datetime] = utc_now + + def validate(self, output: object) -> EconomySimulationOutput: + """Return the call's output; raise ValueError when it is not one.""" + return EconomySimulationOutput.model_validate(output) + + def write( + self, + connection: Connection, + runs: tuple[SimulationRunRecord, ...], + result: object, + ) -> None: + """Link the run's input and output files in the transaction succeeding it.""" + if not isinstance(result, EconomySimulationOutput): + msg = "the economy recorder writes only validated simulation outputs" + raise TypeError(msg) + written_at = self.clock() + for run in runs: + record_output_files( + connection, + [ + OutputFile( + SIMULATION_INPUT_ROLE, + result.input.uri, + result.input.content_sha256, + ), + OutputFile( + SIMULATION_OUTPUT_ROLE, + result.output.uri, + result.output.content_sha256, + ), + ], + written_at=written_at, + simulation_run_id=run.id, + ) + + +def economy_result_recorders() -> ResultRecorders: + """Return the recorders of economy simulation runs and comparison steps.""" + return ResultRecorders( + simulations={SimulationKind.ECONOMY: EconomySimulationRecorder()}, + comparisons={ReportKind.ECONOMY: EconomyComparisonRecorder()}, + ) + + +class WaitingComparisons(Protocol): + """Start comparison steps whose runs have succeeded (``EconomyReports``).""" + + async def start_comparisons_after( + self, simulation_run_ids: Collection[UUID] + ) -> None: + """Start the comparisons waiting for these runs that can start now.""" + ... + + +class ReportedCallRecorder(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.""" + ... + + +_RECORDED = frozenset({CallReportStatus.RECORDED, CallReportStatus.ALREADY_RECORDED}) + + +@dataclass(frozen=True) +class ComparisonStartingRecorder: + """Record a reported call, then start the comparisons its runs complete. + + A simulation call that reports its end usually completes a report run's + pair of runs, so its comparison starts at once. A failure to start one + is logged and changes nothing in the answer: the safety check starts + comparisons that are ready. + """ + + recorder: ReportedCallRecorder + comparisons: WaitingComparisons + + async def record_reported_call(self, report: CallEndedReport) -> CallReportStatus: + """Record the reported call; start the comparisons it lets start.""" + report_status = await self.recorder.record_reported_call(report) + if report.simulation_run_ids and report_status in _RECORDED: + try: + await self.comparisons.start_comparisons_after( + report.simulation_run_ids + ) + except Exception as error: # noqa: BLE001 # logged; the safety check starts it later + logger.warning( + "report_run_comparisons_not_started", + extra={ + "modal_call_id": report.modal_call_id, + "error_type": type(error).__name__, + }, + ) + return report_status diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_report_types.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_report_types.py new file mode 100644 index 000000000..31db24030 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_report_types.py @@ -0,0 +1,239 @@ +"""The requests, errors and store protocols of the v2 economy path. + +``EconomyReports`` (``economy_reports``) runs reports through stores that +satisfy these protocols: ``SimulationStore``, ``ReportStore``, ``RunStore``, +``CatalogReader`` and ``RunTransitions`` in production, and in-memory fakes +in the tests. ``catalog_population`` turns the catalog's region and dataset +rows into the population an economy simulation call carries. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Protocol + +from policyengine_simulation_contract.economy_runs import ( + CatalogPopulation, + CatalogRowFilter, +) + +if TYPE_CHECKING: + from collections.abc import Collection, Mapping + from datetime import datetime + from uuid import UUID + + from policyengine_simulation_persistence import ( + CatalogVersion, + NewReport, + NewReportRun, + NewSimulation, + NewSimulationRun, + OutputFileRecord, + RegionFilter, + RegionPopulation, + ReportRecord, + ReportRunRecord, + RunError, + SimulationKind, + SimulationRecord, + SimulationRunRecord, + StartedReportRun, + StartedSimulationRun, + ) + + +class EconomyRequestError(ValueError): + """The request cannot run: unknown catalog version, region or dataset, or input.""" + + +class ReportNotFoundError(LookupError): + """No report has this ID.""" + + +@dataclass(frozen=True) +class ResumedWork: + """What one safety-check pass started of the work left waiting.""" + + comparisons_started: int = 0 + runs_started: int = 0 + + +@dataclass(frozen=True) +class SimulationChoice: + """A simulation's policy and dynamic; neither means current law.""" + + policy_id: UUID | None = None + dynamic_id: UUID | None = None + + +@dataclass(frozen=True) +class EconomyReportSubmission: + """A new economy report: two simulations of one region, a year, a version. + + ``policyengine_version`` selects the country's catalog version, which + fixes the model, the data release and the region rows. ``dataset_name`` + names a dataset of that version; None means the region's default. + """ + + country: str + policyengine_version: str + year: int + region_code: str + baseline: SimulationChoice + reform: SimulationChoice + dataset_name: str | None = None + options: Mapping[str, Any] = field(default_factory=dict) + + +class SimulationDefinitions(Protocol): + """``SimulationStore``'s finds and reads.""" + + def find_or_create(self, new_simulation: NewSimulation) -> SimulationRecord: + """Return the simulation with this definition, creating it if needed.""" + ... + + def get(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation, or None.""" + ... + + +class ReportDefinitions(Protocol): + """``ReportStore``'s inserts and reads.""" + + def create(self, new_report: NewReport) -> ReportRecord: + """Insert a report.""" + ... + + def get(self, report_id: UUID) -> ReportRecord | None: + """Return the report, or None.""" + ... + + +class EconomyRuns(Protocol): + """``RunStore``'s finds, inserts and reads.""" + + def start_simulation_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Return the reusable run, or the next attempt.""" + ... + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Insert a report run.""" + ... + + def create_first_report_run(self, new_report_run: NewReportRun) -> StartedReportRun: + """Insert the report's first report run, or return its newest one.""" + ... + + def latest_report_run(self, report_id: UUID) -> ReportRunRecord | None: + """Return the report's newest report run, or None.""" + ... + + def get_simulation_run(self, run_id: UUID) -> SimulationRunRecord | None: + """Return the simulation run, or None.""" + ... + + def comparisons_ready_to_start( + self, *, simulation_run_ids: Collection[UUID] | None = None, limit: int = 100 + ) -> list[ReportRunRecord]: + """Return pending comparisons whose runs succeeded.""" + ... + + def simulation_output_file( + self, simulation_run_id: UUID, role: str + ) -> OutputFileRecord | None: + """Return a run's stored file of ``role`` while it is offered.""" + ... + + def pending_simulation_runs( + self, + *, + created_before: datetime, + simulation_kind: SimulationKind | None = None, + limit: int = 100, + ) -> list[SimulationRunRecord]: + """Return runs still pending that were created before a time.""" + ... + + def report_runs_using(self, simulation_run_id: UUID) -> list[ReportRunRecord]: + """Return the report runs comparing a simulation run.""" + ... + + +class EconomyCatalog(Protocol): + """``CatalogReader``'s reads.""" + + def catalog_version( + self, country: str, policyengine_version: str + ) -> CatalogVersion | None: + """Return the country's catalog version for a policyengine.py version.""" + ... + + def catalog_version_by_id(self, version_id: UUID) -> CatalogVersion | None: + """Return a catalog version by ID.""" + ... + + def population( + self, version: CatalogVersion, region_code: str, dataset_name: str | None + ) -> RegionPopulation: + """Return the region and dataset rows; raise CatalogError.""" + ... + + def parameter_changes( + self, + catalog_version_id: UUID, + *, + policy_id: UUID | None, + dynamic_id: UUID | None, + ) -> dict[str, dict[str, Any]]: + """Return the values a policy and dynamic set, checked against a version.""" + ... + + +class FailureTransitions(Protocol): + """The ``RunTransitions`` writes that fail work that cannot start.""" + + def claim_simulation_dispatch(self, run_ids: Collection[UUID]) -> bool: + """Move pending runs to dispatching.""" + ... + + 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_dispatch_failure( + self, report_run_id: UUID, error: RunError + ) -> bool: + """Fail a dispatching comparison step.""" + ... + + +def _row_filter(region_filter: RegionFilter | None) -> CatalogRowFilter | None: + if region_filter is None: + return None + return CatalogRowFilter(field=region_filter.field, value=region_filter.value) + + +def catalog_population(population: RegionPopulation) -> CatalogPopulation: + """Return the population an economy simulation call carries.""" + return CatalogPopulation( + region_code=population.region_code, + dataset_name=population.dataset_name, + row_filter=_row_filter(population.row_filter), + state_code=population.state_code, + state_filters=( + None + if population.state_filters is None + else { + code: CatalogRowFilter(field=item.field, value=item.value) + for code, item in population.state_filters.items() + } + ), + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_reports.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_reports.py new file mode 100644 index 000000000..796a589e2 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_reports.py @@ -0,0 +1,679 @@ +"""Submit and re-run v2 economy reports: simulation runs first, then comparisons. + +A report compares two simulations of one region (``simulation.reports``). +Running it on a catalog version and year: + +1. finds or creates a run of each simulation for that version and year, + reusing one that is unfinished or succeeded within 28 days (the + current-law baseline, most often), and records a report run joining them; +2. starts one Modal call per run it created, with the region and + dataset rows of the run's catalog version and its policy's parameter + values by name (``EconomySimulationCall``); a policy changing a parameter + the version lacks fails its run as ``invalid_input``; +3. starts the report run's comparison step (``EconomyComparisonCall``) once + both runs have succeeded: at once when both were reused, or when a call's + report says that its run succeeded (``start_comparisons_after``). + +A report whose policies change a labour supply elasticity needs both +simulations' labour supply response columns, which a run calculates only +when asked, so its runs are always calculated again (forced) and asked for +them. Report options that would change a simulation's output (an SPM +selection, cliff impacts) are not accepted yet, because a reused run could +not tell which ones it was calculated with. + +Every status change is one of ``RunTransitions``' conditional writes, so a +call starts once whoever else tries. The path's requests, errors and store +protocols are in ``economy_report_types``. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import timedelta +from typing import TYPE_CHECKING, Any, cast + +from policyengine_simulation_contract.economy_runs import ( + ECONOMY_COMPARISON_FUNCTION, + ECONOMY_SIMULATION_FUNCTIONS, + EconomyComparisonCall, + EconomyComparisonSide, + EconomySimulationCall, + policy_affects_labor_supply, +) +from policyengine_simulation_contract.stage12_manifest import v2_application_name +from policyengine_simulation_persistence import ( + CatalogError, + NewReport, + NewReportRun, + NewSimulation, + NewSimulationRun, + ReportKind, + RunError, + RunErrorCode, + RunStatus, + RunTrigger, + SimulationKind, +) +from sqlalchemy.exc import IntegrityError + +from policyengine_simulation_entry.economy_recorders import SIMULATION_OUTPUT_ROLE +from policyengine_simulation_entry.economy_report_types import ( + EconomyRequestError, + ReportNotFoundError, + ResumedWork, + catalog_population, +) +from policyengine_simulation_entry.modal_calls import ModalRequest, ModalTarget +from policyengine_simulation_entry.run_dispatch import DispatchOutcome + +if TYPE_CHECKING: + from collections.abc import Collection, Coroutine + from datetime import datetime + from uuid import UUID + + from policyengine_simulation_contract.stage12_bundle import CountryId + from policyengine_simulation_persistence import ( + CatalogVersion, + RegionPopulation, + ReportRecord, + ReportRunRecord, + SimulationRecord, + SimulationRunRecord, + StartedSimulationRun, + ) + + from policyengine_simulation_entry.economy_report_types import ( + EconomyCatalog, + EconomyReportSubmission, + EconomyRuns, + FailureTransitions, + ReportDefinitions, + SimulationDefinitions, + ) + from policyengine_simulation_entry.run_dispatch import RunDispatchService + +logger = logging.getLogger(__name__) + +RERUN_TRIGGERS = frozenset({RunTrigger.MANUAL, RunTrigger.SYSTEM}) +# A run still pending this long after it was created was left by a submitter +# that stopped before starting its call; the safety check starts it. +PENDING_RUN_START_AFTER = timedelta(minutes=10) +WAITING_WORK_BATCH_SIZE = 50 + + +@dataclass(frozen=True) +class _PlannedRun: + """One simulation of a report run, resolved in the run's catalog version. + + ``changes`` is None when the policy cannot run on the version, and + ``refusal`` then says why. + """ + + simulation: SimulationRecord + population: RegionPopulation + changes: dict[str, dict[str, Any]] | None + refusal: str | None = None + + +@dataclass +class EconomyReports: + """The v2 economy path: report submission, runs, calls and comparisons.""" + + simulations: SimulationDefinitions + reports: ReportDefinitions + runs: EconomyRuns + catalog: EconomyCatalog + transitions: FailureTransitions + dispatch: RunDispatchService + artifact_environment: str + + # --- Reports ----------------------------------------------------------- + + async def submit( + self, submission: EconomyReportSubmission + ) -> tuple[ReportRecord, ReportRunRecord]: + """Create a report's simulations, the report and its first run, and start it. + + Raise EconomyRequestError, before anything is written, when the + country has no catalog for the policyengine.py version, the version + has no such region or dataset, options are given, or a policy or + dynamic does not exist for the country. + """ + if submission.options: + msg = "economy reports take no options yet" + raise EconomyRequestError(msg) + version = await self._version( + submission.country, submission.policyengine_version + ) + await self._population(version, submission.region_code, submission.dataset_name) + try: + baseline, reform = [ + await asyncio.to_thread( + self.simulations.find_or_create, + NewSimulation( + kind=SimulationKind.ECONOMY, + country=submission.country, + policy_id=choice.policy_id, + dynamic_id=choice.dynamic_id, + region_code=submission.region_code, + dataset_name=submission.dataset_name, + ), + ) + for choice in (submission.baseline, submission.reform) + ] + except IntegrityError as error: + msg = ( + f"a policy or dynamic of the submission does not exist for " + f"{submission.country}" + ) + raise EconomyRequestError(msg) from error + report = await asyncio.to_thread( + self.reports.create, + NewReport( + kind=ReportKind.ECONOMY, + country=submission.country, + baseline_simulation_id=baseline.id, + reform_simulation_id=reform.id, + year=submission.year, + ), + ) + report_run = await self._run( + report, version, (baseline, reform), RunTrigger.INITIAL, force=False + ) + return report, report_run + + async def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> ReportRunRecord: + """Run an existing report again on a catalog version. + + ``trigger`` is ``manual`` for a user's re-run and ``system`` for a + run sim's caller starts itself, such as the first opening of a + migrated report. A ``system`` run returns the report's newest run + when it has one, and simultaneous ``system`` runs of a report without + one record a single report run. Existing simulation runs are reused + unless ``force`` is set. Raise ReportNotFoundError for an unknown + report, and EconomyRequestError for one that cannot run. + """ + if trigger not in RERUN_TRIGGERS: + msg = "a report is run again with the manual or system trigger" + raise EconomyRequestError(msg) + report = await asyncio.to_thread(self.reports.get, report_id) + if report is None: + msg = f"no report {report_id}" + raise ReportNotFoundError(msg) + if report.kind is not ReportKind.ECONOMY or report.reform_simulation_id is None: + msg = f"no economy report {report_id}" + raise ReportNotFoundError(msg) + if report.options: + msg = "economy reports take no options yet" + raise EconomyRequestError(msg) + if trigger is RunTrigger.SYSTEM: + newest = await asyncio.to_thread(self.runs.latest_report_run, report_id) + if newest is not None: + return newest + version = await self._version(report.country, policyengine_version) + simulations = [] + for simulation_id in ( + report.baseline_simulation_id, + report.reform_simulation_id, + ): + simulation = await asyncio.to_thread(self.simulations.get, simulation_id) + if simulation is None: # pragma: no cover - a foreign key keeps it + msg = f"no simulation {simulation_id}" + raise ReportNotFoundError(msg) + simulations.append(simulation) + return await self._run( + report, version, (simulations[0], simulations[1]), trigger, force=force + ) + + async def _version(self, country: str, policyengine_version: str) -> CatalogVersion: + version = await asyncio.to_thread( + self.catalog.catalog_version, country, policyengine_version + ) + if version is None: + msg = f"{country} has no catalog for PolicyEngine.py {policyengine_version}" + raise EconomyRequestError(msg) + return version + + async def _population( + self, version: CatalogVersion, region_code: str | None, dataset_name: str | None + ) -> RegionPopulation: + if region_code is None: # pragma: no cover - an economy simulation has one + msg = "an economy simulation names its region" + raise EconomyRequestError(msg) + try: + population = await asyncio.to_thread( + self.catalog.population, version, region_code, dataset_name + ) + except CatalogError as error: + raise EconomyRequestError(str(error)) from error + # Refuse catalog rows a call could not carry before anything is + # written or started. + try: + catalog_population(population) + except ValueError as error: + msg = ( + f"the catalog's {region_code} cannot be sent to a calculation: " + f"{type(error).__name__}" + ) + raise EconomyRequestError(msg) from error + return population + + async def _plan( + self, version: CatalogVersion, simulation: SimulationRecord + ) -> _PlannedRun: + population = await self._population( + version, simulation.region_code, simulation.dataset_name + ) + try: + changes = await asyncio.to_thread( + self.catalog.parameter_changes, + version.id, + policy_id=simulation.policy_id, + dynamic_id=simulation.dynamic_id, + ) + # A policy the version cannot run fails its run, not the request. + except ValueError as error: + return _PlannedRun(simulation, population, None, refusal=str(error)) + return _PlannedRun(simulation, population, changes) + + async def _run( + self, + report: ReportRecord, + version: CatalogVersion, + simulations: tuple[SimulationRecord, SimulationRecord], + trigger: RunTrigger, + *, + force: bool, + ) -> ReportRunRecord: + """Find or create both runs and the report run, then start what can start.""" + planned = [await self._plan(version, simulation) for simulation in simulations] + labor_supply_response = any( + run.changes is not None + and policy_affects_labor_supply(report.country, run.changes) + for run in planned + ) + # A run reused from a report without labour supply responses lacks + # their columns, so such a report calculates its runs again. + force_runs = force or labor_supply_response + started: dict[UUID, StartedSimulationRun] = {} + for run in planned: + if run.simulation.id in started: + continue + started[run.simulation.id] = await asyncio.to_thread( + lambda run=run: self.runs.start_simulation_run( + NewSimulationRun( + simulation_id=run.simulation.id, + tax_benefit_model_version_id=version.id, + year=report.year, + trigger=trigger, + region_code=run.population.region_code, + dataset_id=run.population.dataset_id, + ), + force=force_runs, + ) + ) + baseline, reform = (started[run.simulation.id].run for run in planned) + report_run = await self._record_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline.id, + reform_simulation_run_id=reform.id, + trigger=trigger, + ) + ) + compared = { + run_id + for run_id in ( + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + ) + if run_id is not None + } + # Only the submission that created a run starts it. A reused run + # still pending belongs to the submission that created it, which may + # be a labour supply report asking for more columns; if that + # submitter stopped, the safety check starts it for every report + # run comparing it. + dispatched: set[UUID] = set() + for run in planned: + started_run = started[run.simulation.id] + if not started_run.created or started_run.run.id in dispatched: + continue + dispatched.add(started_run.run.id) + if started_run.run.id not in compared: + # Another first open of the report recorded its run with its + # own runs. This one stays pending: another report may have + # reused it meanwhile, and the safety check starts it for + # them, or fails it when nothing compares it. + continue + await self._start_simulation_run( + started_run.run, + run, + version, + labor_supply_response=labor_supply_response, + ) + await self.start_comparisons_after(compared) + return report_run + + async def _record_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Record a report run; a ``system`` one only while the report has none. + + Simultaneous first opens of a report without a run then share one + report run. A caller that loses still starts the runs it created + that the recorded run compares, and leaves the others pending for the + safety check. + """ + if new_report_run.trigger is not RunTrigger.SYSTEM: + return await asyncio.to_thread(self.runs.create_report_run, new_report_run) + first = await asyncio.to_thread( + self.runs.create_first_report_run, new_report_run + ) + return first.run + + # --- Simulation runs --------------------------------------------------- + + async def _start_simulation_run( + self, + simulation_run: SimulationRunRecord, + planned: _PlannedRun, + version: CatalogVersion, + *, + labor_supply_response: bool, + ) -> bool: + """Start the run's call if it has not started; fail it if it cannot run. + + Return whether this call started it. + """ + if simulation_run.status is not RunStatus.PENDING: + return False + if planned.changes is None: + await self._fail_simulation_run( + simulation_run.id, + RunError(RunErrorCode.INVALID_INPUT, planned.refusal or ""), + ) + return False + call = EconomySimulationCall( + simulation_run_id=simulation_run.id, + country=cast("CountryId", planned.simulation.country), + year=simulation_run.year, + policyengine_version=version.policyengine_version, + policy=planned.changes, + population=catalog_population(planned.population), + labor_supply_response=labor_supply_response, + artifact_environment=self.artifact_environment, + ) + outcome = await self.dispatch.start_simulation_runs( + [simulation_run.id], + ModalRequest( + target=ModalTarget( + application=v2_application_name(version.policyengine_version), + function=ECONOMY_SIMULATION_FUNCTIONS[call.country], + ), + arguments=(call.model_dump(mode="json"),), + ), + ) + return outcome is DispatchOutcome.STARTED + + # --- Work left waiting ------------------------------------------------- + + async def resume_waiting_work(self, *, now: datetime) -> ResumedWork: + """Start what a stopped instance or a lost report left waiting. + + The safety check runs this after recording due calls: comparison + steps whose runs both succeeded, and runs still pending + ``PENDING_RUN_START_AFTER`` after they were created. A run's call + asks for labour supply columns when any report run comparing it + needs them. + """ + ready = await asyncio.to_thread( + lambda: self.runs.comparisons_ready_to_start(limit=WAITING_WORK_BATCH_SIZE) + ) + comparisons_started = 0 + for report_run in ready: + comparisons_started += await self._resume( + self.start_comparison(report_run), report_run_id=str(report_run.id) + ) + stranded = await asyncio.to_thread( + lambda: self.runs.pending_simulation_runs( + created_before=now - PENDING_RUN_START_AFTER, + simulation_kind=SimulationKind.ECONOMY, + limit=WAITING_WORK_BATCH_SIZE, + ) + ) + runs_started = 0 + for simulation_run in stranded: + runs_started += await self._resume( + self._start_stranded_run(simulation_run), + simulation_run_id=str(simulation_run.id), + ) + return ResumedWork( + comparisons_started=comparisons_started, runs_started=runs_started + ) + + @staticmethod + async def _resume(start: Coroutine[Any, Any, bool], **subject: str) -> int: + """Start one piece of waiting work; return 1 if its call started. + + A failure is logged and leaves the item for the next pass, so one + item cannot stop the others. + """ + try: + return int(await start) + except Exception as error: # noqa: BLE001 # logged; the next pass tries again + logger.warning( + "economy_work_not_resumed", + extra={**subject, "error_type": type(error).__name__}, + ) + return 0 + + async def _labor_supply_needed( + self, report_runs: list[ReportRunRecord], version: CatalogVersion + ) -> bool: + """Return whether any of these report runs compares an elasticity change. + + A run every one of them compares must then calculate the labour + supply response columns. + """ + for report_run in report_runs: + report = await asyncio.to_thread(self.reports.get, report_run.report_id) + if report is None: # pragma: no cover - a foreign key keeps it + continue + for simulation_id in ( + report.baseline_simulation_id, + report.reform_simulation_id, + ): + compared = ( + None + if simulation_id is None + else await asyncio.to_thread(self.simulations.get, simulation_id) + ) + if compared is None: # pragma: no cover - a foreign key keeps it + continue + planned = await self._plan(version, compared) + if planned.changes is not None and policy_affects_labor_supply( + report.country, planned.changes + ): + return True + return False + + async def _start_stranded_run(self, simulation_run: SimulationRunRecord) -> bool: + """Start a run left pending; return whether its call started. + + A run no report run compares, or that its catalog version can no + longer resolve, is failed, so the safety check stops picking it. + """ + version = await asyncio.to_thread( + self.catalog.catalog_version_by_id, + simulation_run.tax_benefit_model_version_id, + ) + simulation = await asyncio.to_thread( + self.simulations.get, simulation_run.simulation_id + ) + if version is None or simulation is None: # pragma: no cover - foreign keys + return False + report_runs = await asyncio.to_thread( + self.runs.report_runs_using, simulation_run.id + ) + if not report_runs: + await self._fail_simulation_run( + simulation_run.id, + RunError( + RunErrorCode.DISPATCH_ABANDONED, + "No report run compares the run, so no call was started.", + ), + ) + return False + try: + planned = await self._plan(version, simulation) + except EconomyRequestError as error: + await self._fail_simulation_run( + simulation_run.id, RunError(RunErrorCode.INVALID_INPUT, str(error)) + ) + return False + return await self._start_simulation_run( + simulation_run, + planned, + version, + labor_supply_response=await self._labor_supply_needed(report_runs, version), + ) + + async def _fail_simulation_run(self, run_id: UUID, error: RunError) -> None: + """Fail a pending run that cannot start, if no one else has claimed it.""" + if await asyncio.to_thread( + self.transitions.claim_simulation_dispatch, [run_id] + ): + await asyncio.to_thread( + self.transitions.record_simulation_dispatch_failure, [run_id], error + ) + + # --- Comparisons ------------------------------------------------------- + + async def start_comparisons_after( + self, simulation_run_ids: Collection[UUID] + ) -> None: + """Start the comparison steps whose two runs have now both succeeded.""" + ready = await asyncio.to_thread( + lambda: self.runs.comparisons_ready_to_start( + simulation_run_ids=set(simulation_run_ids) + ) + ) + for report_run in ready: + await self.start_comparison(report_run) + + async def start_comparison(self, report_run: ReportRunRecord) -> bool: + """Start a report run's comparison step with both runs' stored outputs. + + A step whose output files are no longer offered, or whose report can + no longer be resolved in its catalog version, is failed. + """ + report = await asyncio.to_thread(self.reports.get, report_run.report_id) + version = await asyncio.to_thread( + self.catalog.catalog_version_by_id, report_run.tax_benefit_model_version_id + ) + if ( + report is None + or version is None + or report_run.reform_simulation_run_id is None + ): + return False + sides = [] + for simulation_id, run_id in ( + (report.baseline_simulation_id, report_run.baseline_simulation_run_id), + (report.reform_simulation_id, report_run.reform_simulation_run_id), + ): + simulation = ( + None + if simulation_id is None + else await asyncio.to_thread(self.simulations.get, simulation_id) + ) + output = await asyncio.to_thread( + self.runs.simulation_output_file, run_id, SIMULATION_OUTPUT_ROLE + ) + if simulation is None or output is None: + await self._fail_comparison( + report_run.id, + RunError( + RunErrorCode.RESULT_EXPIRED, + "A simulation run's stored output is no longer offered.", + ), + ) + return False + try: + planned = await self._plan(version, simulation) + except EconomyRequestError as error: + await self._fail_comparison( + report_run.id, RunError(RunErrorCode.INVALID_INPUT, str(error)) + ) + return False + if planned.changes is None: + await self._fail_comparison( + report_run.id, + RunError(RunErrorCode.INVALID_INPUT, planned.refusal or ""), + ) + return False + sides.append((planned, run_id, output)) + ( + (baseline, baseline_run_id, baseline_output), + ( + reform, + reform_run_id, + reform_output, + ), + ) = sides + call = EconomyComparisonCall( + report_run_id=report_run.id, + country=cast("CountryId", report.country), + year=report_run.year, + policyengine_version=version.policyengine_version, + region_code=baseline.population.region_code, + dataset_name=baseline.population.dataset_name, + baseline=EconomyComparisonSide( + simulation_run_id=baseline_run_id, + policy=baseline.changes or {}, + output_uri=baseline_output.uri, + output_sha256=baseline_output.sha256, + ), + reform=EconomyComparisonSide( + simulation_run_id=reform_run_id, + policy=reform.changes or {}, + output_uri=reform_output.uri, + output_sha256=reform_output.sha256, + ), + ) + outcome = await self.dispatch.start_comparison( + report_run.id, + ModalRequest( + target=ModalTarget( + application=v2_application_name(version.policyengine_version), + function=ECONOMY_COMPARISON_FUNCTION, + ), + arguments=(call.model_dump(mode="json"),), + ), + ) + return outcome is DispatchOutcome.STARTED + + async def _fail_comparison(self, report_run_id: UUID, error: RunError) -> None: + if await asyncio.to_thread( + self.transitions.claim_comparison_dispatch, report_run_id + ): + await asyncio.to_thread( + self.transitions.record_comparison_dispatch_failure, + report_run_id, + error, + ) + logger.warning( + "report_run_comparison_not_started", + extra={ + "report_run_id": str(report_run_id), + "error_code": error.code.value, + }, + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_routes.py new file mode 100644 index 000000000..e7a8cddc8 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_routes.py @@ -0,0 +1,511 @@ +"""The v2 economy report routes, and the status reads they answer with. + +- ``POST /v2/economy-reports``: create a report and its first run, and start + it (``EconomyReports.submit``); 201. +- ``GET /v2/economy-reports/{report_id}`` and + ``GET /v2/economy-reports/by-legacy-id/{legacy_report_id}``: the report, + its newest run and, once that run's comparison succeeded, its result, + rebuilt from its rows in today's format (``SingleYearMacroOutput``). +- ``POST /v2/economy-reports/{report_id}/runs``: run it again; 201. A + ``system`` run (the first opening of a report migrated without a run) + returns the report's newest run when it has one, so simultaneous first + openings share one run. +- ``GET /v2/economy-reports/{report_id}/runs``: its runs, newest first, each + with its catalog version. +- ``GET /v2/simulations/{simulation_id}``: a simulation, so a caller can + check that one exists before saving its ID. + +Status reads only read. Submissions need ``simulation:submit`` and reads +``simulation:read``. Without the simulation database, or the Modal +environment sim starts calls in, every route answers 503. + +Each route declares its error answers in the OpenAPI document, so the +generated client parses them: 404 and 503 as ``EconomyErrorResponse``, and +422 as ``EconomyRefusalResponse``, whose ``detail`` is sim's reason as text +or FastAPI's list of refused fields. The report reads also answer 500, as +``EconomyErrorResponse``, when a succeeded run's stored result cannot be +rebuilt, for example when it names an area its catalog version lacks. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol +from uuid import UUID # noqa: TC003 # FastAPI evaluates the handlers' path parameters + +from fastapi import Depends, FastAPI, HTTPException, status +from policyengine_simulation_contract.job_progress import JobStage +from policyengine_simulation_persistence import ReportKind, RunStatus, RunTrigger +from sqlalchemy.exc import DBAPIError + +from policyengine_simulation_entry.economy_report_types import ( + EconomyReportSubmission, + EconomyRequestError, + ReportDefinitions, + ReportNotFoundError, + SimulationChoice, +) +from policyengine_simulation_entry.economy_schemas import ( + ComparisonResponse, + EconomyErrorResponse, + EconomyRefusalResponse, + EconomyReportResponse, + EconomyReportRunRequest, + EconomyReportRunResponse, + EconomyReportRunsResponse, + EconomyReportSubmissionRequest, + ReportStatus, + RunErrorResponse, + SimulationResponse, + SimulationRunResponse, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from policyengine_simulation_contract.macro_output import SingleYearMacroOutput + from policyengine_simulation_persistence import ( + ReportRecord, + ReportRunRecord, + SimulationRecord, + SimulationRunRecord, + ) + + from policyengine_simulation_entry.auth import AuthorizationDependency + from policyengine_simulation_entry.economy_report_types import ( + EconomyCatalog, + SimulationDefinitions, + ) + from policyengine_simulation_entry.economy_reports import EconomyReports + +logger = logging.getLogger(__name__) + +REFUSED: dict[int | str, dict[str, Any]] = { + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "model": EconomyRefusalResponse, + "description": ( + "Refused: sim cannot run the request (its reason as text), or a " + "request field is invalid (one entry per field)" + ), + }, +} +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: { + "model": EconomyErrorResponse, + "description": "No such economy report or simulation", + }, +} +RESULT_UNREADABLE: dict[int | str, dict[str, Any]] = { + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "model": EconomyErrorResponse, + "description": "The report's stored result cannot be read back", + }, +} +UNAVAILABLE: dict[int | str, dict[str, Any]] = { + status.HTTP_503_SERVICE_UNAVAILABLE: { + "model": EconomyErrorResponse, + "description": ( + "The simulation database cannot be reached, or economy reports " + "are not configured on this deployment" + ), + }, +} + + +class StoredResultError(Exception): + """A succeeded report run's stored result cannot be rebuilt.""" + + def __init__(self, report_run_id: UUID, cause: LookupError) -> None: + """Name the report run and keep the reader's error as the cause.""" + super().__init__(f"report run {report_run_id}: {cause}") + self.report_run_id = report_run_id + + +class ResultReader(Protocol): + """Rebuild a succeeded comparison's result from its rows.""" + + def read_result( + self, report_run_id: UUID, catalog_version_id: UUID, country: str + ) -> SingleYearMacroOutput: + """Return the result; raise LookupError when it cannot be rebuilt.""" + ... + + +class ReportReads(ReportDefinitions, Protocol): + """``ReportStore``'s reads by v1 report number.""" + + def get_by_legacy_report_id(self, legacy_report_id: int) -> ReportRecord | None: + """Return the report migrated from this v1 report, or None.""" + ... + + +class RunReads(Protocol): + """``RunStore``'s reads.""" + + def get_simulation_run(self, run_id: UUID) -> SimulationRunRecord | None: + """Return a simulation run, or None.""" + ... + + def report_runs(self, report_id: UUID) -> list[ReportRunRecord]: + """Return a report's runs, newest first.""" + ... + + +def _error(code: str | None, message: str | None) -> RunErrorResponse | None: + return None if code is None else RunErrorResponse(code=code, message=message) + + +def _simulation(record: SimulationRecord) -> SimulationResponse: + return SimulationResponse( + id=record.id, + kind=record.kind.value, + country=record.country, + policy_id=record.policy_id, + dynamic_id=record.dynamic_id, + region_code=record.region_code, + dataset_name=record.dataset_name, + legacy_simulation_id=record.legacy_simulation_id, + created_at=record.created_at, + ) + + +def _simulation_run(record: SimulationRunRecord) -> SimulationRunResponse: + return SimulationRunResponse( + id=record.id, + simulation_id=record.simulation_id, + status=record.status.value, + attempt=record.attempt, + trigger=record.trigger.value, + error=_error(record.error_code, record.error_message), + dispatched_at=record.dispatched_at, + completed_at=record.completed_at, + created_at=record.created_at, + ) + + +def _progress( + report_run: ReportRunRecord, + runs: tuple[SimulationRunRecord, SimulationRunRecord], +) -> tuple[ReportStatus, JobStage | None, RunErrorResponse | None]: + """Return a report run's status, its stage while it runs, and its error. + + A failed simulation run fails the report run at once, before the safety + check fails its comparison step. + """ + failed = next((run for run in runs if run.status is RunStatus.FAILED), None) + if failed is not None: + return ( + ReportStatus.FAILED, + None, + _error(failed.error_code, failed.error_message), + ) + comparison = report_run.comparison_status + if comparison is RunStatus.FAILED: + return ( + ReportStatus.FAILED, + None, + _error( + report_run.comparison_error_code, report_run.comparison_error_message + ), + ) + if comparison is RunStatus.SUCCEEDED: + return ReportStatus.SUCCEEDED, None, None + if all(run.status is RunStatus.SUCCEEDED for run in runs): + return ReportStatus.RUNNING, JobStage.OUTPUTS, None + if all(run.status is RunStatus.PENDING for run in runs): + return ReportStatus.PENDING, JobStage.QUEUED, None + return ReportStatus.RUNNING, JobStage.SIMULATING, None + + +@dataclass +class EconomyReportReads: + """Build report and report run responses from the stored rows.""" + + simulations: SimulationDefinitions + reports: ReportReads + runs: RunReads + catalog: EconomyCatalog + results: ResultReader + + def report_run(self, report_run: ReportRunRecord) -> EconomyReportRunResponse: + """Describe one report run with its simulation runs and comparison.""" + reform_run_id = ( + report_run.reform_simulation_run_id or report_run.baseline_simulation_run_id + ) + baseline = self.runs.get_simulation_run(report_run.baseline_simulation_run_id) + reform = self.runs.get_simulation_run(reform_run_id) + if baseline is None or reform is None: # pragma: no cover - foreign keys + msg = f"report run {report_run.id} has no simulation run" + raise LookupError(msg) + version = self.catalog.catalog_version_by_id( + report_run.tax_benefit_model_version_id + ) + report_status, stage, error = _progress(report_run, (baseline, reform)) + return EconomyReportRunResponse( + id=report_run.id, + report_id=report_run.report_id, + trigger=report_run.trigger.value, + catalog_version_id=report_run.tax_benefit_model_version_id, + policyengine_version=None + if version is None + else version.policyengine_version, + year=report_run.year, + status=report_status, + stage=stage, + error=error, + baseline_run=_simulation_run(baseline), + reform_run=_simulation_run(reform), + comparison=ComparisonResponse( + status=(report_run.comparison_status or RunStatus.PENDING).value, + error=_error( + report_run.comparison_error_code, + report_run.comparison_error_message, + ), + dispatched_at=report_run.comparison_dispatched_at, + completed_at=report_run.comparison_completed_at, + ), + created_at=report_run.created_at, + ) + + def report(self, report: ReportRecord) -> EconomyReportResponse: + """Describe a report with its newest run and, once compared, its result.""" + simulations = [ + self.simulations.get(simulation_id) + for simulation_id in ( + report.baseline_simulation_id, + report.reform_simulation_id or report.baseline_simulation_id, + ) + ] + baseline, reform = simulations + if baseline is None or reform is None: # pragma: no cover - foreign keys + msg = f"report {report.id} has no simulation" + raise LookupError(msg) + runs = self.runs.report_runs(report.id) + latest = self.report_run(runs[0]) if runs else None + result = None + if latest is not None and latest.status is ReportStatus.SUCCEEDED: + try: + result = self.results.read_result( + latest.id, latest.catalog_version_id, report.country + ) + except LookupError as error: + raise StoredResultError(latest.id, error) from error + return EconomyReportResponse( + id=report.id, + country=report.country, + year=report.year, + options=report.options, + legacy_report_id=report.legacy_report_id, + created_at=report.created_at, + baseline_simulation=_simulation(baseline), + reform_simulation=_simulation(reform), + status=ReportStatus.NOT_RUN if latest is None else latest.status, + run=latest, + result=result, + ) + + def report_by_id(self, report_id: UUID) -> EconomyReportResponse: + """Describe a report; raise ReportNotFoundError for an unknown one.""" + return self.report(self._report(self.reports.get(report_id), report_id)) + + def report_by_legacy_id(self, legacy_report_id: int) -> EconomyReportResponse: + """Describe the report migrated from a v1 report number.""" + return self.report( + self._report( + self.reports.get_by_legacy_report_id(legacy_report_id), + legacy_report_id, + ) + ) + + def report_runs(self, report_id: UUID) -> EconomyReportRunsResponse: + """List a report's runs, newest first.""" + self._report(self.reports.get(report_id), report_id) + return EconomyReportRunsResponse( + runs=[self.report_run(run) for run in self.runs.report_runs(report_id)] + ) + + def simulation(self, simulation_id: UUID) -> SimulationResponse: + """Describe a simulation; raise ReportNotFoundError for an unknown one.""" + record = self.simulations.get(simulation_id) + if record is None: + msg = f"no simulation {simulation_id}" + raise ReportNotFoundError(msg) + return _simulation(record) + + @staticmethod + def _report(record: ReportRecord | None, identifier: object) -> ReportRecord: + """Return an economy report; a household report is not one.""" + if record is None or record.kind is not ReportKind.ECONOMY: + msg = f"no economy report {identifier}" + raise ReportNotFoundError(msg) + return record + + +@dataclass(frozen=True) +class EconomyRouteServices: + """What the routes run on: the economy path and its status reads.""" + + reports: EconomyReports + reads: EconomyReportReads + + +async def _answer[ResultT](call: Callable[[], Awaitable[ResultT]]) -> ResultT: + """Run a route's work, turning sim's refusals into HTTP errors.""" + try: + return await call() + except ReportNotFoundError as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(error)) from None + except EconomyRequestError as error: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(error)) from None + except StoredResultError as error: + logger.error( # noqa: TRY400 # result failure logs carry only the run ID and the error type; a traceback could expose stored result data + "economy_report_result_unreadable", + extra={ + "report_run_id": str(error.report_run_id), + "error_type": type(error.__cause__).__name__, + }, + ) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "The report's stored result cannot be read back.", + ) from None + except DBAPIError as error: + logger.warning( + "economy_report_database_unavailable", + extra={"error_type": type(error).__name__}, + ) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "The simulation database cannot be reached.", + ) from None + + +def _configured(services: EconomyRouteServices | None) -> EconomyRouteServices: + if services is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Economy reports are not configured on this deployment.", + ) + return services + + +def register_economy_routes( + app: FastAPI, + *, + services: EconomyRouteServices | None, + submitter: AuthorizationDependency, + reader: AuthorizationDependency, +) -> None: + """Add the v2 economy report routes.""" + + @app.post( + "/v2/economy-reports", + status_code=status.HTTP_201_CREATED, + response_model=EconomyReportResponse, + operation_id="submit_economy_report", + responses={**REFUSED, **UNAVAILABLE}, + dependencies=[Depends(submitter)], + ) + async def submit_economy_report( + body: EconomyReportSubmissionRequest, + ) -> EconomyReportResponse: + economy = _configured(services) + + async def submit() -> EconomyReportResponse: + report, _ = await economy.reports.submit( + EconomyReportSubmission( + country=body.country, + policyengine_version=body.policyengine_version, + year=body.year, + region_code=body.region_code, + dataset_name=body.dataset_name, + baseline=SimulationChoice(**body.baseline.model_dump()), + reform=SimulationChoice(**body.reform.model_dump()), + options=body.options, + ) + ) + return await asyncio.to_thread(economy.reads.report, report) + + return await _answer(submit) + + @app.get( + "/v2/economy-reports/by-legacy-id/{legacy_report_id}", + response_model=EconomyReportResponse, + operation_id="get_economy_report_by_legacy_id", + responses={**NOT_FOUND, **REFUSED, **RESULT_UNREADABLE, **UNAVAILABLE}, + dependencies=[Depends(reader)], + ) + async def get_economy_report_by_legacy_id( + legacy_report_id: int, + ) -> EconomyReportResponse: + economy = _configured(services) + return await _answer( + lambda: asyncio.to_thread( + economy.reads.report_by_legacy_id, legacy_report_id + ) + ) + + @app.get( + "/v2/economy-reports/{report_id}", + response_model=EconomyReportResponse, + operation_id="get_economy_report", + responses={**NOT_FOUND, **REFUSED, **RESULT_UNREADABLE, **UNAVAILABLE}, + dependencies=[Depends(reader)], + ) + async def get_economy_report(report_id: UUID) -> EconomyReportResponse: + economy = _configured(services) + return await _answer( + lambda: asyncio.to_thread(economy.reads.report_by_id, report_id) + ) + + @app.post( + "/v2/economy-reports/{report_id}/runs", + status_code=status.HTTP_201_CREATED, + response_model=EconomyReportRunResponse, + operation_id="run_economy_report", + responses={**NOT_FOUND, **REFUSED, **UNAVAILABLE}, + dependencies=[Depends(submitter)], + ) + async def run_economy_report( + report_id: UUID, body: EconomyReportRunRequest + ) -> EconomyReportRunResponse: + economy = _configured(services) + + async def run() -> EconomyReportRunResponse: + report_run = await economy.reports.run_report( + report_id, + policyengine_version=body.policyengine_version, + trigger=RunTrigger(body.trigger), + force=body.force, + ) + return await asyncio.to_thread(economy.reads.report_run, report_run) + + return await _answer(run) + + @app.get( + "/v2/economy-reports/{report_id}/runs", + response_model=EconomyReportRunsResponse, + operation_id="list_economy_report_runs", + responses={**NOT_FOUND, **REFUSED, **UNAVAILABLE}, + dependencies=[Depends(reader)], + ) + async def list_economy_report_runs(report_id: UUID) -> EconomyReportRunsResponse: + economy = _configured(services) + return await _answer( + lambda: asyncio.to_thread(economy.reads.report_runs, report_id) + ) + + @app.get( + "/v2/simulations/{simulation_id}", + response_model=SimulationResponse, + operation_id="get_simulation", + responses={**NOT_FOUND, **REFUSED, **UNAVAILABLE}, + dependencies=[Depends(reader)], + ) + async def get_simulation(simulation_id: UUID) -> SimulationResponse: + economy = _configured(services) + return await _answer( + lambda: asyncio.to_thread(economy.reads.simulation, simulation_id) + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_schemas.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_schemas.py new file mode 100644 index 000000000..c2804f141 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/economy_schemas.py @@ -0,0 +1,184 @@ +"""Request and response bodies of the v2 economy report routes.""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Literal +from uuid import UUID + +from policyengine_simulation_contract.job_progress import JobStage +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +CountryCode = Literal["us", "uk"] +CatalogKey = Annotated[str, Field(min_length=1, max_length=255, pattern=r"^\S+$")] +PolicyEngineVersion = Annotated[str, Field(min_length=1, max_length=128)] + + +class _Request(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class SimulationChoiceRequest(_Request): + """A simulation's policy and dynamic; neither means current law.""" + + policy_id: UUID | None = None + dynamic_id: UUID | None = None + + +class EconomyReportSubmissionRequest(_Request): + """A new economy report and its first run. + + ``policyengine_version`` selects the country's catalog version. + ``region_code`` is a region of that version (``state/ca``, + ``country/wales``), and ``dataset_name`` one of its datasets; without it + the region's default dataset is used. ``options`` must be empty for now. + """ + + country: CountryCode + policyengine_version: PolicyEngineVersion + year: Annotated[int, Field(ge=1900, le=2200)] + region_code: CatalogKey + dataset_name: CatalogKey | None = None + baseline: SimulationChoiceRequest + reform: SimulationChoiceRequest + options: dict[str, JsonValue] = Field(default_factory=dict) + + +class EconomyReportRunRequest(_Request): + """Run a report again on a catalog version. + + ``manual`` is a user's re-run and ``system`` a run the caller starts + itself, such as the first opening of a migrated report; a ``system`` run + returns the report's newest run when it has one. Existing simulation + runs are reused unless ``force`` is set. + """ + + policyengine_version: PolicyEngineVersion + trigger: Literal["manual", "system"] + force: bool = False + + +class ReportStatus(StrEnum): + """Where a report or report run stands; a report's is its newest run's.""" + + NOT_RUN = "not_run" + PENDING = "pending" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class SimulationResponse(BaseModel): + """A simulation: one policy applied to one region.""" + + id: UUID + kind: str + country: str + policy_id: UUID | None + dynamic_id: UUID | None + region_code: str | None + dataset_name: str | None + legacy_simulation_id: int | None + created_at: datetime + + +class RunErrorResponse(BaseModel): + """Why a run failed, without user data.""" + + code: str + message: str | None + + +class SimulationRunResponse(BaseModel): + """One execution of a simulation for a catalog version and year.""" + + id: UUID + simulation_id: UUID + status: str + attempt: int + trigger: str + error: RunErrorResponse | None + dispatched_at: datetime | None + completed_at: datetime | None + created_at: datetime + + +class ComparisonResponse(BaseModel): + """A report run's comparison step.""" + + status: str + error: RunErrorResponse | None + dispatched_at: datetime | None + completed_at: datetime | None + + +class EconomyReportRunResponse(BaseModel): + """One calculation of a report: two simulation runs and their comparison. + + ``stage`` says what the run is doing while it runs: ``queued`` before + any call has started, ``simulating`` while a simulation runs, and + ``outputs`` while the comparison runs. + """ + + id: UUID + report_id: UUID + trigger: str + catalog_version_id: UUID + policyengine_version: str | None + year: int + status: ReportStatus + stage: JobStage | None + error: RunErrorResponse | None + baseline_run: SimulationRunResponse + reform_run: SimulationRunResponse + comparison: ComparisonResponse + created_at: datetime + + +class EconomyReportRunsResponse(BaseModel): + """A report's runs, newest first.""" + + runs: list[EconomyReportRunResponse] + + +class EconomyReportResponse(BaseModel): + """An economy report, its newest run and, once compared, its result.""" + + id: UUID + country: str + year: int + options: dict[str, JsonValue] + legacy_report_id: int | None + created_at: datetime + baseline_simulation: SimulationResponse + reform_simulation: SimulationResponse + status: ReportStatus + run: EconomyReportRunResponse | None + result: SingleYearMacroOutput | None + + +class EconomyErrorResponse(BaseModel): + """Why sim did not answer the request: no such record, or not available.""" + + detail: str + + +class EconomyRequestFieldError(BaseModel): + """One request field FastAPI's validation refused.""" + + loc: list[str | int] + msg: str + type: str + + +class EconomyRefusalResponse(BaseModel): + """A refused request (422). + + ``detail`` is text when sim refuses a request it cannot run (an unknown + catalog version, region or dataset, or options), and one entry per field + when FastAPI's validation refuses the request's fields. + """ + + detail: str | list[EconomyRequestFieldError] 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/modal_calls.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py new file mode 100644 index 000000000..96ec899bf --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_calls.py @@ -0,0 +1,223 @@ +"""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. 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. +""" + +from __future__ import annotations + +import asyncio +import traceback +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, 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.""" + + 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 + + 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() + + +# Where Modal raises a finished call's own outcome in this process: the +# 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, + KeyboardInterrupt, + SystemExit, + GeneratorExit, +) + + +def raised_from_call_result(error: BaseException) -> bool: + """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: + 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. + + 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) + 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/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..cecb7190c --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/modal_identity.py @@ -0,0 +1,155 @@ +"""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 + # 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, + 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/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..27685d6e2 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_dispatch.py @@ -0,0 +1,313 @@ +"""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 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 the +safety check fails them as abandoned. The economy report routes and the +safety check start calls through it (``economy_reports``). +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +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 + from uuid import UUID + + from policyengine_simulation_entry.modal_calls import CallDispatcher, ModalRequest + +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 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): + """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, 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 call's report + # or the safety check records its outcome; if not, the safety check fails + # the runs as abandoned. + RECORD_UNKNOWN = "record_unknown" + + +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 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.""" + ... + + 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.""" + ... + + 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 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 + + +@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 + ), + holds_call=lambda call_id: self.transitions.simulation_runs_hold_call( + runs, call_id + ), + event_prefix="simulation_run", + ), + 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 + ), + holds_call=lambda call_id: self.transitions.comparison_holds_call( + report_run_id, call_id + ), + event_prefix="report_run_comparison", + ), + 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( + _event_name(steps.event_prefix, "dispatch_failed"), + extra={**subject, "error_type": type(error).__name__}, + ) + await asyncio.to_thread( + steps.fail, + RunError( + RunErrorCode.DISPATCH_FAILED, + f"Starting the Modal call raised {type(error).__name__}.", + ), + ) + return DispatchOutcome.DISPATCH_FAILED + 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. + """ + 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: # settled below + logger.exception( + _event_name(steps.event_prefix, "call_record_failed"), + extra={**context, "error_type": type(error).__name__}, + ) + 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 a recording pass failed them as abandoned. + 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(_event_name(steps.event_prefix, "dispatched"), extra=context) + return DispatchOutcome.STARTED + + 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( + _event_name(event_prefix, "call_not_cancelled"), + extra={**context, "error_type": type(error).__name__}, + ) + + 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: + failed = await asyncio.to_thread( + steps.fail, + RunError( + RunErrorCode.DISPATCH_FAILED, + "The Modal call could not be recorded.", + ), + ) + 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, "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/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..30a00fc3f --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -0,0 +1,600 @@ +"""Record each Modal call's outcome on its runs, one pass of due checks at a time. + +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 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, 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 +no database credentials: sim writes every outcome. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import timedelta +from enum import StrEnum +from typing import TYPE_CHECKING, Protocol + +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 ( + OWN_PROCESS_SIGNALS, + CallFailed, + CallRunning, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from datetime import datetime + from uuid import UUID + + from policyengine_simulation_contract.call_reports import CallEndedReport + from policyengine_simulation_persistence import ( + AbandonedDispatches, + ReportKind, + ReportRunRecord, + SimulationKind, + SimulationRunRecord, + ) + from sqlalchemy import Connection + + from policyengine_simulation_entry.modal_calls import CallInspector, CallOutcome + +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}" + + +# 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 +# Each check writes through the runtime engine's small pool, which requests +# share (policyengine_simulation_persistence.engine: 2 connections plus 3 +# overflow), so a pass uses at most three at once. +INSPECTION_CONCURRENCY = 3 + + +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 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 + ) + comparisons: Mapping[ReportKind, ComparisonResultRecorder] = field( + default_factory=dict + ) + + +class RecordingTransitions(Protocol): + """The ``RunTransitions`` writes a recording pass needs.""" + + def claim_due_simulation_calls( + self, *, limit: int, lease: timedelta + ) -> list[DueSimulationCall]: + """Claim running simulation calls that are due a 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 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.""" + ... + + def fail_comparisons_of_failed_runs(self) -> tuple[UUID, ...]: + """Fail pending comparison steps whose simulation runs failed.""" + ... + + +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.""" + + checked: int = 0 + succeeded: int = 0 + failed: int = 0 + still_running: int = 0 + not_reached: int = 0 + abandoned: int = 0 + comparisons_without_runs: int = 0 + without_recorder: int = 0 + # Economy work the pass started after recording: comparisons whose runs + # had succeeded, and runs left pending by a submitter that stopped. + comparisons_started: int = 0 + runs_started: int = 0 + + @property + 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: + """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] + # "simulation_run" or "report_run_comparison": the log event names' start. + event_prefix: str + result_steps: _ResultSteps | None + fail: Callable[[RunError], 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 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 + + async def record_due_outcomes(self) -> RecordingPass: + """Run one pass: fail abandoned dispatches, then check every due call.""" + recording = RecordingPass() + 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 + ) + ) + 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 + + 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 + ) + 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 + ) + 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]: + 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], + }, + event_prefix="simulation_run", + result_steps=result_steps, + fail=lambda error: self.transitions.fail_simulation_call(call_id, error), + ) + + 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)}, + event_prefix="report_run_comparison", + result_steps=result_steps, + fail=lambda error: self.transitions.fail_comparison(report_run_id, error), + ) + + 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) + 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 + # 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"), + 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) -> _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): + return _Recorded.STILL_RUNNING + if isinstance(outcome, CallFailed): + 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. + logger.warning( + _event_name(handlers.event_prefix, "without_result_recorder"), + extra=handlers.subject, + ) + 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, output) + except ValueError as error: + 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 call is checked again. + except Exception as error: + logger.exception( + _event_name(handlers.event_prefix, "result_not_written"), + 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 + + +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/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..fd8335000 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py @@ -0,0 +1,177 @@ +"""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; +- starts the economy work left waiting: comparison steps whose runs both + succeeded, and runs a submitter left pending (``SafetyCheckPass``). + +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``, sim's Modal token (``MODAL_TOKEN_ID``, +``MODAL_TOKEN_SECRET``), and the Modal environment and deployment it starts +economy calls in (``SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT``, +``APP_ENVIRONMENT``). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from dataclasses import asdict, dataclass +from functools import partial +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_observability.observability import ( + init_process_observability, +) +from policyengine_simulation_persistence.run_store import utc_now + +from policyengine_simulation_entry.economy_report_types import ResumedWork +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 datetime import datetime + + from policyengine_simulation_entry.run_recording import RecordingPass + +__all__ = ["ResumedWork", "SafetyCheckPass", "main"] + +logger = logging.getLogger(__name__) + +EXIT_PASSED = 0 +EXIT_PASS_FAILED = 1 +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``).""" + + async def record_due_outcomes(self) -> RecordingPass: + """Fail abandoned dispatches, then check every due call.""" + ... + + +class WaitingWork(Protocol): + """Start the economy work left waiting (``EconomyReports``).""" + + async def resume_waiting_work(self, *, now: datetime) -> ResumedWork: + """Start ready comparisons and runs left pending.""" + ... + + +@dataclass(frozen=True) +class SafetyCheckPass: + """One pass: record the due calls, then start the work left waiting.""" + + recorder: DueOutcomeRecorder + waiting: WaitingWork | None + clock: Callable[[], datetime] = utc_now + + async def record_due_outcomes(self) -> RecordingPass: + """Record due calls' outcomes, then start waiting comparisons and runs.""" + recording = await self.recorder.record_due_outcomes() + if self.waiting is not None: + resumed = await self.waiting.resume_waiting_work(now=self.clock()) + recording.comparisons_started = resumed.comparisons_started + recording.runs_started = resumed.runs_started + return recording + + +def _database_recorder( + database_url: str, *, environment: Mapping[str, str] +) -> DueOutcomeRecorder: + """Build the pass; it starts waiting work when the Modal environment is set.""" + database = SimulationDatabase.from_url(database_url) + modal_environment = environment.get("SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT", "") + waiting = ( + database.economy_routes( + artifact_environment=environment.get("APP_ENVIRONMENT", "local").lower(), + modal_environment=modal_environment, + ).reports + if modal_environment + else None + ) + return SafetyCheckPass(database.call_recorder(), waiting) + + +def main( + *, + environment: Mapping[str, str] | None = None, + build_recorder: Callable[[str], DueOutcomeRecorder] | None = None, + start_observability: Callable[[str], Telemetry] = _start_observability, +) -> int: + """Run one pass and return the process exit status. + + 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. Without + ``build_recorder``, the pass reads its settings from ``environment``. + """ + values = os.environ if environment is None else environment + builder = build_recorder or partial(_database_recorder, environment=values) + telemetry = start_observability(values.get("APP_ENVIRONMENT", "local")) + try: + return _run_pass(values, builder) + 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") + 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 + # 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.getLogger().setLevel(logging.INFO) + sys.exit(main()) 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..17b8f7f90 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -0,0 +1,135 @@ +"""The Simulation Entrypoint's connection to its own ``simulation`` schema.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence import ( + CatalogReader, + EconomyResultLabels, + ReportStore, + RunStore, + RunTransitions, + SimulationStore, + create_simulation_engine, + read_economy_comparison, + read_schema_revision, +) + +from policyengine_simulation_entry.economy_recorders import economy_result_recorders +from policyengine_simulation_entry.economy_reports import EconomyReports +from policyengine_simulation_entry.economy_routes import ( + EconomyReportReads, + EconomyRouteServices, +) +from policyengine_simulation_entry.modal_calls import ( + ModalCallDispatcher, + ModalCallInspector, +) +from policyengine_simulation_entry.run_dispatch import RunDispatchService +from policyengine_simulation_entry.run_recording import RunRecorder + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_simulation_contract.macro_output import SingleYearMacroOutput + from sqlalchemy import Engine + + +@dataclass(frozen=True) +class DatabaseResultReader: + """Rebuild a comparison's result from its rows, with UK area names.""" + + engine: Engine + catalog: CatalogReader + + def read_result( + self, report_run_id: UUID, catalog_version_id: UUID, country: str + ) -> SingleYearMacroOutput: + """Return the result in today's format; raise LookupError if it cannot be.""" + labels = ( + self.catalog.area_labels(catalog_version_id) + if country == "uk" + else EconomyResultLabels() + ) + with self.engine.connect() as connection: + return read_economy_comparison(connection, report_run_id, labels) + + +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 call_recorder(self) -> RunRecorder: + """Return what records a reported call's outcome on its runs. + + Economy simulation runs and comparison steps have result recorders. A + finished call of another kind is answered no_result_recorder and left + for a revision that records it. + """ + return RunRecorder( + transitions=RunTransitions(self.engine), + runs=RunStore(self.engine), + inspector=ModalCallInspector(), + recorders=economy_result_recorders(), + ) + + def economy_routes( + self, *, artifact_environment: str, modal_environment: str + ) -> EconomyRouteServices: + """Return the economy path, starting calls in ``modal_environment``. + + ``artifact_environment`` names the deployment in stored files' paths. + """ + engine = self.engine + simulations = SimulationStore(engine) + reports = ReportStore(engine) + runs = RunStore(engine) + catalog = CatalogReader(engine) + transitions = RunTransitions(engine) + return EconomyRouteServices( + reports=EconomyReports( + simulations=simulations, + reports=reports, + runs=runs, + catalog=catalog, + transitions=transitions, + dispatch=RunDispatchService( + transitions=transitions, + dispatcher=ModalCallDispatcher(modal_environment), + ), + artifact_environment=artifact_environment, + ), + reads=EconomyReportReads( + simulations=simulations, + reports=reports, + runs=runs, + catalog=catalog, + results=DatabaseResultReader(engine, catalog), + ), + ) + + def dispose(self) -> None: + """Close every pooled connection.""" + self.engine.dispose() 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/economy_fakes.py b/projects/policyengine-simulation-entry/tests/economy_fakes.py new file mode 100644 index 000000000..584c12c00 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/economy_fakes.py @@ -0,0 +1,461 @@ +"""In-memory stores, catalog and transitions for the v2 economy path's tests.""" + +from __future__ import annotations + +from dataclasses import dataclass, field, fields, replace +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, + CatalogError, + CatalogVersion, + OutputFileRecord, + RegionFilter, + RegionPopulation, + ReportKind, + ReportRecord, + ReportRunRecord, + RunStatus, + SimulationKind, + SimulationRecord, + SimulationRunRecord, + StartedReportRun, + StartedSimulationRun, +) + +if TYPE_CHECKING: + from collections.abc import Collection + + from policyengine_simulation_persistence import ( + NewReport, + NewReportRun, + NewSimulation, + NewSimulationRun, + RunError, + ) + +NOW = datetime(2026, 10, 27, 12, tzinfo=UTC) +US_VERSION = CatalogVersion(id=uuid4(), country="us", policyengine_version="6.7.5") +UK_VERSION = CatalogVersion(id=uuid4(), country="uk", policyengine_version="6.7.5") +STATE_DATASET_ID = uuid4() +NATIONAL_DATASET_ID = uuid4() +CALIFORNIA = RegionPopulation( + region_code="state/ca", + region_type="state", + dataset_id=STATE_DATASET_ID, + dataset_name="populace_us_2024_acs_local", + row_filter=RegionFilter(field="state_fips", value="6"), + state_code="CA", + state_filters=None, +) +UNITED_STATES = RegionPopulation( + region_code="us", + region_type="national", + dataset_id=NATIONAL_DATASET_ID, + dataset_name="populace_us_2024", + row_filter=None, + state_code=None, + state_filters={"state/ca": RegionFilter(field="state_fips", value="6")}, +) +CTC_REFORM = {"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01": 3000}} +LABOR_SUPPLY_REFORM = { + "gov.simulation.labor_supply_responses.elasticities.income": {"2026-01-01": 0.1} +} + + +class FakeCatalog: + """Two catalog versions, California and the US, and scripted policies.""" + + def __init__(self) -> None: + self.versions = {("us", "6.7.5"): US_VERSION, ("uk", "6.7.5"): UK_VERSION} + self.populations = {"state/ca": CALIFORNIA, "us": UNITED_STATES} + self.changes: dict[UUID | None, dict[str, Any] | Exception] = {None: {}} + + def catalog_version(self, country: str, policyengine_version: str): + return self.versions.get((country, policyengine_version)) + + def catalog_version_by_id(self, version_id: UUID): + return next( + (version for version in self.versions.values() if version.id == version_id), + None, + ) + + def population(self, version, region_code: str, dataset_name: str | None): + population = self.populations.get(region_code) + if population is None: + msg = f"version {version.policyengine_version} has no region {region_code}" + raise CatalogError(msg) + if dataset_name not in {None, population.dataset_name}: + msg = f"no dataset {dataset_name}" + raise CatalogError(msg) + return population + + def parameter_changes(self, _version_id, *, policy_id, dynamic_id): + del dynamic_id + changes = self.changes[policy_id] + if isinstance(changes, Exception): + raise changes + return changes + + def area_labels(self, _version_id): + from policyengine_simulation_persistence import EconomyResultLabels + + return EconomyResultLabels() + + +class FakeSimulations: + def __init__(self) -> None: + self.rows: dict[UUID, SimulationRecord] = {} + self.policies_of_country: dict[str, set[UUID | None]] = { + "us": {None}, + "uk": {None}, + } + + def find_or_create(self, new_simulation: NewSimulation) -> SimulationRecord: + if ( + new_simulation.policy_id + not in self.policies_of_country[new_simulation.country] + ): + from sqlalchemy.exc import IntegrityError + + statement = "INSERT" + raise IntegrityError( + statement, {}, Exception("fk_simulations_policy_country") + ) + for row in self.rows.values(): + if all( + getattr(row, name) == getattr(new_simulation, name) + for name in ( + "kind", + "country", + "policy_id", + "dynamic_id", + "household_id", + "region_code", + "dataset_name", + ) + ): + return row + row = SimulationRecord( + id=uuid4(), + created_at=NOW, + **{ + item.name: getattr(new_simulation, item.name) + for item in fields(new_simulation) + }, + ) + self.rows[row.id] = row + return row + + def get(self, simulation_id: UUID) -> SimulationRecord | None: + return self.rows.get(simulation_id) + + +class FakeReports: + def __init__(self) -> None: + self.rows: dict[UUID, ReportRecord] = {} + + def create(self, new_report: NewReport) -> ReportRecord: + row = ReportRecord( + id=uuid4(), + created_at=NOW, + **{ + item.name: getattr(new_report, item.name) for item in fields(new_report) + }, + ) + self.rows[row.id] = row + return row + + def get(self, report_id: UUID) -> ReportRecord | None: + return self.rows.get(report_id) + + def get_by_legacy_report_id(self, legacy_report_id: int) -> ReportRecord | None: + return next( + ( + row + for row in self.rows.values() + if row.legacy_report_id == legacy_report_id + ), + None, + ) + + +def simulation_run(**values: Any) -> SimulationRunRecord: + defaults: dict[str, Any] = { + "id": uuid4(), + "simulation_kind": SimulationKind.ECONOMY, + "year": 2026, + "attempt": 1, + "region_code": "state/ca", + "dataset_id": STATE_DATASET_ID, + "status": RunStatus.PENDING, + "modal_call_id": None, + "error_code": None, + "error_message": None, + "check_count": 0, + "dispatched_at": None, + "next_check_at": None, + "completed_at": None, + "created_at": NOW, + "updated_at": NOW, + } + defaults.update(values) + return SimulationRunRecord(**defaults) + + +@dataclass +class FakeRuns: + """Runs, report runs and their files, with RunStore's reuse rules.""" + + clock: Any = lambda: NOW + simulation_runs: dict[UUID, SimulationRunRecord] = field(default_factory=dict) + report_runs_by_id: dict[UUID, ReportRunRecord] = field(default_factory=dict) + files: dict[tuple[UUID, str], OutputFileRecord] = field(default_factory=dict) + + # --- RunStore ---------------------------------------------------------- + + def start_simulation_run(self, new_run: NewSimulationRun, *, force: bool = False): + attempts = sorted( + ( + run + for run in self.simulation_runs.values() + if run.simulation_id == new_run.simulation_id + and run.tax_benefit_model_version_id + == new_run.tax_benefit_model_version_id + and run.year == new_run.year + ), + key=lambda run: run.attempt, + ) + latest = attempts[-1] if attempts else None + if latest is not None and not force and self._reusable(latest): + return StartedSimulationRun(run=latest, created=False) + run = simulation_run( + simulation_id=new_run.simulation_id, + tax_benefit_model_version_id=new_run.tax_benefit_model_version_id, + year=new_run.year, + attempt=1 if latest is None else latest.attempt + 1, + region_code=new_run.region_code, + dataset_id=new_run.dataset_id, + trigger=new_run.trigger, + ) + self.simulation_runs[run.id] = run + return StartedSimulationRun(run=run, created=True) + + def _reusable(self, run: SimulationRunRecord) -> bool: + if run.status in {RunStatus.PENDING, RunStatus.DISPATCHING, RunStatus.RUNNING}: + return True + return ( + run.status is RunStatus.SUCCEEDED + and run.completed_at is not None + and run.completed_at >= self.clock() - SUCCEEDED_RUN_REUSE_WINDOW + ) + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + baseline = self.simulation_runs[new_report_run.baseline_simulation_run_id] + row = ReportRunRecord( + id=uuid4(), + report_id=new_report_run.report_id, + report_kind=ReportKind.ECONOMY, + 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, + comparison_status=RunStatus.PENDING, + comparison_modal_call_id=None, + comparison_error_code=None, + comparison_error_message=None, + comparison_check_count=0, + comparison_dispatched_at=None, + comparison_next_check_at=None, + comparison_completed_at=None, + created_at=self.clock() + + timedelta(microseconds=len(self.report_runs_by_id)), + updated_at=self.clock(), + ) + self.report_runs_by_id[row.id] = row + return row + + def get_simulation_run(self, run_id: UUID): + return self.simulation_runs.get(run_id) + + def get_report_run(self, report_run_id: UUID): + return self.report_runs_by_id.get(report_run_id) + + def report_runs(self, report_id: UUID) -> list[ReportRunRecord]: + return sorted( + ( + run + for run in self.report_runs_by_id.values() + if run.report_id == report_id + ), + key=lambda run: run.created_at, + reverse=True, + ) + + def latest_report_run(self, report_id: UUID): + runs = self.report_runs(report_id) + return runs[0] if runs else None + + def create_first_report_run(self, new_report_run: NewReportRun): + newest = self.latest_report_run(new_report_run.report_id) + if newest is not None: + return StartedReportRun(run=newest, created=False) + return StartedReportRun( + run=self.create_report_run(new_report_run), created=True + ) + + def comparisons_ready_to_start(self, *, simulation_run_ids=None, limit=100): + ready = [ + run + for run in self.report_runs_by_id.values() + if run.comparison_status is RunStatus.PENDING + and self.simulation_runs[run.baseline_simulation_run_id].status + is RunStatus.SUCCEEDED + and run.reform_simulation_run_id is not None + and self.simulation_runs[run.reform_simulation_run_id].status + is RunStatus.SUCCEEDED + and ( + simulation_run_ids is None + or {run.baseline_simulation_run_id, run.reform_simulation_run_id} + & set(simulation_run_ids) + ) + ] + return ready[:limit] + + def pending_simulation_runs( + self, *, created_before, simulation_kind=None, limit=100 + ): + return [ + run + for run in self.simulation_runs.values() + if run.status is RunStatus.PENDING + and run.created_at < created_before + and simulation_kind in {None, run.simulation_kind} + ][:limit] + + def report_runs_using(self, simulation_run_id: UUID) -> list[ReportRunRecord]: + return [ + run + for run in self.report_runs_by_id.values() + if simulation_run_id + in {run.baseline_simulation_run_id, run.reform_simulation_run_id} + ] + + def simulation_output_file(self, simulation_run_id, role, *, now=None): + del now + return self.files.get((simulation_run_id, role)) + + # --- Test set-up ------------------------------------------------------- + + def succeed(self, run_id: UUID, *, days_ago: int = 0) -> None: + completed = self.clock() - timedelta(days=days_ago) + self.simulation_runs[run_id] = replace( + self.simulation_runs[run_id], + status=RunStatus.SUCCEEDED, + modal_call_id=f"fc-done-{run_id.hex[:6]}", + completed_at=completed, + ) + self.files[(run_id, "simulation_output")] = OutputFileRecord( + id=uuid4(), + simulation_run_id=run_id, + report_run_id=None, + role="simulation_output", + uri=f"gs://private/stage-12-runs/staging/2026/10/{run_id}/output.parquet", + sha256="a" * 64, + written_at=completed, + ) + + # --- RunTransitions ---------------------------------------------------- + + def _change_runs(self, run_ids, expected: RunStatus, **values: Any) -> bool: + wanted = set(run_ids) + if any( + self.simulation_runs[run_id].status is not expected for run_id in wanted + ): + return False + for run_id in wanted: + self.simulation_runs[run_id] = replace( + self.simulation_runs[run_id], **values + ) + return True + + def claim_simulation_dispatch(self, run_ids: Collection[UUID]) -> bool: + return self._change_runs( + run_ids, RunStatus.PENDING, status=RunStatus.DISPATCHING + ) + + def record_simulation_dispatched(self, run_ids, *, modal_call_id, first_check_at): + 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, error: RunError) -> bool: + return self._change_runs( + run_ids, + RunStatus.DISPATCHING, + status=RunStatus.FAILED, + error_code=error.code.value, + error_message=error.message, + completed_at=self.clock(), + ) + + def simulation_runs_hold_call(self, run_ids, modal_call_id) -> bool: + return all( + self.simulation_runs[run_id].modal_call_id == modal_call_id + for run_id in run_ids + ) + + def _change_comparison(self, report_run_id, expected: RunStatus, **values) -> bool: + run = self.report_runs_by_id[report_run_id] + if run.comparison_status is not expected: + return False + self.report_runs_by_id[report_run_id] = replace(run, **values) + return True + + def claim_comparison_dispatch(self, report_run_id: UUID) -> bool: + run = self.report_runs_by_id[report_run_id] + if not all( + self.simulation_runs[run_id].status is RunStatus.SUCCEEDED + for run_id in (run.baseline_simulation_run_id, run.reform_simulation_run_id) + if run_id is not None + ): + return False + return self._change_comparison( + report_run_id, RunStatus.PENDING, comparison_status=RunStatus.DISPATCHING + ) + + def record_comparison_dispatched( + self, report_run_id, *, modal_call_id, first_check_at + ): + return self._change_comparison( + report_run_id, + RunStatus.DISPATCHING, + comparison_status=RunStatus.RUNNING, + comparison_modal_call_id=modal_call_id, + comparison_next_check_at=first_check_at, + ) + + def record_comparison_dispatch_failure( + self, report_run_id, error: RunError + ) -> bool: + return self._change_comparison( + report_run_id, + RunStatus.DISPATCHING, + comparison_status=RunStatus.FAILED, + comparison_error_code=error.code.value, + comparison_error_message=error.message, + comparison_completed_at=self.clock(), + ) + + def comparison_holds_call(self, report_run_id, modal_call_id) -> bool: + return ( + self.report_runs_by_id[report_run_id].comparison_modal_call_id + == modal_call_id + ) diff --git a/projects/policyengine-simulation-entry/tests/fixtures/economy_result.json b/projects/policyengine-simulation-entry/tests/fixtures/economy_result.json new file mode 100644 index 000000000..2609876a0 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/fixtures/economy_result.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/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..8b899767d --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/migration_wiring_checks.py @@ -0,0 +1,67 @@ +"""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, *, 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")) + 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, *, 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: + 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, 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", [])} + 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/run_fakes.py b/projects/policyengine-simulation-entry/tests/run_fakes.py new file mode 100644 index 000000000..acee770ed --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -0,0 +1,504 @@ +"""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 ( + CallInspectionError, + CallRunning, + ModalRequest, + ModalTarget, +) +from policyengine_simulation_persistence import ( + AbandonedDispatches, + DueSimulationCall, + RunError, + RunErrorCode, + RunStatus, + SimulationKind, +) + +if TYPE_CHECKING: + from collections.abc import Collection + + from policyengine_simulation_entry.modal_calls import CallOutcome + + +@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) + + +@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 + status: RunStatus = RunStatus.RUNNING + + +@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 + comparison_status: RunStatus | None = RunStatus.RUNNING + + +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] = {} + self.comparisons_with_failed_runs: set[UUID] = set() + + # --- 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, 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 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]: + """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 + ) + + 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 + + # --- 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( + 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 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 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_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: + """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.""" + + 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.""" + self.requests.append(request) + if self.failure is not None: + raise self.failure + 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"},), + 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_call_report_deployment.py b/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py new file mode 100644 index 000000000..cb09bfad3 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_call_report_deployment.py @@ -0,0 +1,139 @@ +"""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, +) +from workflow_script_support import FakeCommands, github_output, run_script + + +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 }}" + + +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-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_call_report_route.py b/projects/policyengine-simulation-entry/tests/test_call_report_route.py new file mode 100644 index 000000000..ebdeb996b --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_call_report_route.py @@ -0,0 +1,198 @@ +"""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"] + + +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_config.py b/projects/policyengine-simulation-entry/tests/test_config.py index 8dbf6809f..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(): @@ -297,3 +297,81 @@ 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) + + +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_database_access_boundaries.py b/projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py index 81d7ad590..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", @@ -34,8 +35,10 @@ "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", + "migrations", ) diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index a47600cbc..187f403df 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -11,12 +11,16 @@ DEPLOY_WORKFLOW, REPOSITORY_ROOT, REUSABLE_DEPLOY_WORKFLOW, + REUSABLE_DEPLOY_WORKFLOW_REFERENCE, SMOKE_SCRIPT, STAGE12_DEPLOY_WORKFLOW, STAGE12_VALIDATION_SCRIPT, + STAGING_PROMOTION_FIXTURES, TRAFFIC_SCRIPT, + WORKFLOWS_DIRECTORY, run_bash, smoke_test_env, + workflow_jobs, write_fake_smoke_curl, ) @@ -198,8 +202,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 @@ -210,8 +214,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, the simulation + # database migration and the staging economy check 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 @@ -267,3 +272,78 @@ def test_deployment_uses_gcloud_workflow_without_terraform(): / "configure-cloud-run-simulation-entry-domains.sh" ).exists() _assert_stage12_deploy_targets(stage12_workflow) + + +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) == [] 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..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 @@ -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, ) @@ -39,7 +41,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 +116,15 @@ 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" + ), } 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 +158,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" @@ -159,6 +166,75 @@ 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() + + +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") + 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")], @@ -169,7 +245,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 +268,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..71b05bd87 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_support.py @@ -34,10 +34,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" ) @@ -128,7 +138,22 @@ 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"}' + 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) versions='{}' 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..df2e4e669 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py @@ -6,6 +6,7 @@ import textwrap import tomllib +from deployment_rule_checks import libraries_missing_from_context from test_deployment_assets_support import ( DOCKERIGNORE, GITIGNORE, @@ -172,6 +173,40 @@ def test_container_context_excludes_local_environments_and_unrelated_projects(): ) +def test_container_context_includes_every_local_library_the_entrypoint_installs(): + 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") + ) + == [] + ) + + def test_production_lock_contains_the_direct_stage12_database_runtime(): lockfile = tomllib.loads( ( diff --git a/projects/policyengine-simulation-entry/tests/test_economy_recorders.py b/projects/policyengine-simulation-entry/tests/test_economy_recorders.py new file mode 100644 index 000000000..6331149a6 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_economy_recorders.py @@ -0,0 +1,219 @@ +"""How sim records a v2 economy call's outcome: stored files, then result rows.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from policyengine_simulation_contract.call_reports import ( + CallEndedReport, + CallReportStatus, +) +from policyengine_simulation_entry.economy_recorders import ( + SIMULATION_INPUT_ROLE, + SIMULATION_OUTPUT_ROLE, + ComparisonStartingRecorder, + EconomySimulationRecorder, + economy_result_recorders, +) +from policyengine_simulation_entry.simulation_database import SimulationDatabase +from policyengine_simulation_persistence import ( + EconomyComparisonRecorder, + ReportKind, + SimulationKind, +) +from policyengine_simulation_persistence.output_files import output_files + +WRITTEN_AT = datetime(2026, 10, 27, 12, tzinfo=UTC) +RUN_ID = uuid4() + + +def _reference(uri: str, media_type: str, digest: str) -> dict: + return { + "uri": uri, + "media_type": media_type, + "content_sha256": digest, + "size_bytes": 10, + } + + +def _output(run_id=RUN_ID) -> dict: + prefix = f"gs://private/stage-12-runs/staging/2026/10/{run_id}" + return { + "schema_version": 1, + "simulation_run_id": str(run_id), + "input": _reference( + f"{prefix}/inputs/baseline-1.json", "application/json", "1" * 64 + ), + "output": _reference( + f"{prefix}/simulations/baseline-2.parquet", + "application/vnd.apache.parquet", + "2" * 64, + ), + "output_plan_sha256": "3" * 64, + "row_identity": { + "identifier_columns": ["household.household_id"], + "row_count": 2, + "identity_sha256": "4" * 64, + }, + "bundle": { + "policyengine_version": "6.7.5", + "country_package_name": "policyengine-us", + "country_package_version": "1.764.6", + "dataset": { + "identity": "populace_us_2024", + "uri": "hf://policyengine/data/populace_us_2024.h5@revision", + "artifact_revision": "revision", + "data_package_name": "populace-data", + "data_package_version": "0.1.0", + }, + "bundle_manifest_sha256": "b" * 64, + }, + } + + +class _Connection: + """Record the statements and rows a writer executes.""" + + def __init__(self) -> None: + self.executed: list[tuple[object, object]] = [] + + def execute(self, statement, rows=None): + self.executed.append((statement, rows)) + + +def test_a_finished_simulation_call_records_the_files_it_stored() -> None: + recorder = EconomySimulationRecorder(clock=lambda: WRITTEN_AT) + connection = _Connection() + + result = recorder.validate(_output()) + recorder.write(connection, (SimpleNamespace(id=RUN_ID),), result) + + ((statement, rows),) = connection.executed + assert statement.table is output_files + assert [ + (row["simulation_run_id"], row["role"], row["uri"], row["sha256"]) + for row in rows + ] == [ + (RUN_ID, SIMULATION_INPUT_ROLE, result.input.uri, "1" * 64), + (RUN_ID, SIMULATION_OUTPUT_ROLE, result.output.uri, "2" * 64), + ] + assert {row["written_at"] for row in rows} == {WRITTEN_AT} + + +def test_an_output_that_is_not_a_simulation_output_fails_validation() -> None: + with pytest.raises(ValueError, match="validation error"): + EconomySimulationRecorder().validate({"budget": {}}) + + +def test_sim_records_economy_simulations_and_comparisons() -> None: + recorders = economy_result_recorders() + + assert isinstance( + recorders.simulations[SimulationKind.ECONOMY], EconomySimulationRecorder + ) + assert isinstance( + recorders.comparisons[ReportKind.ECONOMY], EconomyComparisonRecorder + ) + + +def test_the_simulation_database_records_economy_outcomes() -> None: + database = SimulationDatabase.from_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + + recorders = database.call_recorder().recorders + + assert SimulationKind.ECONOMY in recorders.simulations + assert ReportKind.ECONOMY in recorders.comparisons + database.dispose() + + +class _Recorder: + def __init__(self, answer: CallReportStatus) -> None: + self.answer = answer + self.reports: list[CallEndedReport] = [] + + async def record_reported_call(self, report: CallEndedReport) -> CallReportStatus: + self.reports.append(report) + return self.answer + + +class _Comparisons: + def __init__(self, failure: Exception | None = None) -> None: + self.failure = failure + self.started_after: list[tuple] = [] + + async def start_comparisons_after(self, simulation_run_ids) -> None: + self.started_after.append(tuple(simulation_run_ids)) + if self.failure is not None: + raise self.failure + + +SIMULATION_REPORT = CallEndedReport( + modal_call_id="fc-sim", simulation_run_ids=(RUN_ID,) +) +COMPARISON_REPORT = CallEndedReport(modal_call_id="fc-cmp", report_run_id=uuid4()) + + +@pytest.mark.parametrize( + "answer", [CallReportStatus.RECORDED, CallReportStatus.ALREADY_RECORDED] +) +def test_a_recorded_simulation_call_starts_the_comparisons_it_completes( + answer, +) -> None: + comparisons = _Comparisons() + recorder = ComparisonStartingRecorder(_Recorder(answer), comparisons) + + assert asyncio.run(recorder.record_reported_call(SIMULATION_REPORT)) is answer + assert comparisons.started_after == [(RUN_ID,)] + + +@pytest.mark.parametrize( + ("report", "answer"), + [ + (SIMULATION_REPORT, CallReportStatus.STILL_RUNNING), + (SIMULATION_REPORT, CallReportStatus.NOT_WRITTEN), + (COMPARISON_REPORT, CallReportStatus.RECORDED), + ], +) +def test_nothing_starts_until_a_simulation_calls_outcome_is_recorded( + report, answer +) -> None: + comparisons = _Comparisons() + recorder = ComparisonStartingRecorder(_Recorder(answer), comparisons) + + asyncio.run(recorder.record_reported_call(report)) + + assert comparisons.started_after == [] + + +def test_a_comparison_that_cannot_start_leaves_the_answer_unchanged(caplog) -> None: + comparisons = _Comparisons(failure=ConnectionError("database")) + recorder = ComparisonStartingRecorder( + _Recorder(CallReportStatus.RECORDED), comparisons + ) + + answer = asyncio.run(recorder.record_reported_call(SIMULATION_REPORT)) + + assert answer is CallReportStatus.RECORDED + assert "report_run_comparisons_not_started" in caplog.text + + +def test_the_simulation_database_starts_economy_calls_in_its_modal_environment() -> ( + None +): + database = SimulationDatabase.from_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + + services = database.economy_routes( + artifact_environment="staging", modal_environment="staging" + ) + + assert services.reports.artifact_environment == "staging" + assert services.reports.dispatch.dispatcher._environment == "staging" + database.dispose() diff --git a/projects/policyengine-simulation-entry/tests/test_economy_reports.py b/projects/policyengine-simulation-entry/tests/test_economy_reports.py new file mode 100644 index 000000000..197a72805 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_economy_reports.py @@ -0,0 +1,753 @@ +"""Submitting and re-running v2 economy reports: runs, reuse, calls, comparisons.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from datetime import timedelta +from uuid import uuid4 + +import pytest +from economy_fakes import ( + CALIFORNIA, + CTC_REFORM, + LABOR_SUPPLY_REFORM, + US_VERSION, + FakeCatalog, + FakeReports, + FakeRuns, + FakeSimulations, +) +from policyengine_simulation_contract.economy_runs import ( + EconomyComparisonCall, + EconomySimulationCall, +) +from policyengine_simulation_contract.stage12_manifest import v2_application_name +from policyengine_simulation_entry.economy_report_types import ( + EconomyReportSubmission, + EconomyRequestError, + ReportNotFoundError, + SimulationChoice, +) +from policyengine_simulation_entry.economy_reports import EconomyReports +from policyengine_simulation_entry.run_dispatch import RunDispatchService +from policyengine_simulation_persistence import ( + NewReport, + NewReportRun, + NewSimulation, + NewSimulationRun, + PolicyParametersMissingError, + ReportKind, + RunErrorCode, + RunStatus, + RunTrigger, + SimulationKind, +) +from run_fakes import FakeDispatcher + +REFORM_POLICY = uuid4() +LABOR_SUPPLY_POLICY = uuid4() +MISSING_POLICY = uuid4() +OTHER_POLICY = uuid4() + + +class _Path: + """The economy path over in-memory stores, with every spawned request kept.""" + + def __init__(self, runs: FakeRuns | None = None) -> None: + self.catalog = FakeCatalog() + self.catalog.changes.update( + { + REFORM_POLICY: CTC_REFORM, + LABOR_SUPPLY_POLICY: LABOR_SUPPLY_REFORM, + MISSING_POLICY: PolicyParametersMissingError(("gov.retired",)), + OTHER_POLICY: { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01": 2500} + }, + } + ) + self.simulations = FakeSimulations() + self.simulations.policies_of_country["us"].update( + {REFORM_POLICY, LABOR_SUPPLY_POLICY, MISSING_POLICY, OTHER_POLICY} + ) + self.reports = FakeReports() + self.runs = FakeRuns() if runs is None else runs + self.dispatcher = FakeDispatcher() + self.economy = EconomyReports( + simulations=self.simulations, + reports=self.reports, + runs=self.runs, + catalog=self.catalog, + transitions=self.runs, + dispatch=RunDispatchService( + transitions=self.runs, dispatcher=self.dispatcher, clock=self.runs.clock + ), + artifact_environment="staging", + ) + + def submit(self, **changes: object): + values: dict[str, object] = { + "country": "us", + "policyengine_version": "6.7.5", + "year": 2026, + "region_code": "state/ca", + "baseline": SimulationChoice(), + "reform": SimulationChoice(policy_id=REFORM_POLICY), + } + values.update(changes) + return asyncio.run( + self.economy.submit(EconomyReportSubmission(**values)) # type: ignore[arg-type] + ) + + def simulation_calls(self) -> list[EconomySimulationCall]: + return [ + EconomySimulationCall.model_validate(request.arguments[0]) + for request in self.dispatcher.requests + if request.target.function.startswith("run_economy_simulation") + ] + + def comparison_calls(self) -> list[EconomyComparisonCall]: + return [ + EconomyComparisonCall.model_validate(request.arguments[0]) + for request in self.dispatcher.requests + if request.target.function == "compare_economy_simulations" + ] + + +def test_a_submission_starts_one_call_per_simulation_run_of_its_report() -> None: + path = _Path() + + report, report_run = path.submit() + + assert report.year == 2026 + assert report.options == {} + assert report_run.trigger is RunTrigger.INITIAL + assert report_run.tax_benefit_model_version_id == US_VERSION.id + calls = {call.simulation_run_id: call for call in path.simulation_calls()} + assert set(calls) == { + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + } + reform = calls[report_run.reform_simulation_run_id] + assert reform.policy == CTC_REFORM + assert reform.population.region_code == "state/ca" + assert reform.population.dataset_name == "populace_us_2024_acs_local" + assert reform.population.row_filter is not None + assert reform.population.row_filter.household_value() == 6 + assert reform.population.state_code == "CA" + assert reform.labor_supply_response is False + assert reform.artifact_environment == "staging" + assert calls[report_run.baseline_simulation_run_id].policy == {} + assert {request.target.application for request in path.dispatcher.requests} == { + v2_application_name("6.7.5") + } + assert {path.runs.simulation_runs[run_id].status for run_id in calls} == { + RunStatus.RUNNING + } + assert path.comparison_calls() == [] + + +def test_a_us_national_run_sends_each_states_filter() -> None: + path = _Path() + + path.submit(region_code="us") + + population = path.simulation_calls()[0].population + assert population.row_filter is None + assert population.state_filters is not None + assert population.state_filters["state/ca"].household_value() == 6 + + +def test_current_law_that_already_ran_is_reused_and_only_the_reform_starts() -> None: + path = _Path() + _, first = path.submit() + path.runs.succeed(first.baseline_simulation_run_id) + path.dispatcher.requests.clear() + + _, second = path.submit(reform=SimulationChoice(policy_id=OTHER_POLICY)) + + assert second.baseline_simulation_run_id == first.baseline_simulation_run_id + assert [call.simulation_run_id for call in path.simulation_calls()] == [ + second.reform_simulation_run_id + ] + + +def test_a_report_whose_runs_both_succeeded_starts_its_comparison_at_once() -> None: + path = _Path() + _, first = path.submit() + path.runs.succeed(first.baseline_simulation_run_id) + path.runs.succeed(first.reform_simulation_run_id) + path.dispatcher.requests.clear() + + _, second = path.submit() + + assert path.simulation_calls() == [] + # The first report run's comparison was waiting for the same two runs. + comparisons = {call.report_run_id: call for call in path.comparison_calls()} + assert set(comparisons) == {first.id, second.id} + comparison = comparisons[second.id] + assert comparison.baseline.simulation_run_id == first.baseline_simulation_run_id + assert comparison.reform.policy == CTC_REFORM + assert comparison.reform.output_uri.endswith("output.parquet") + assert comparison.region_code == "state/ca" + assert comparison.dataset_name == "populace_us_2024_acs_local" + assert path.runs.report_runs_by_id[second.id].comparison_status is ( + RunStatus.RUNNING + ) + + +def test_a_run_compared_with_itself_is_one_run_and_one_call() -> None: + path = _Path() + + _, report_run = path.submit(reform=SimulationChoice()) + + assert report_run.baseline_simulation_run_id == report_run.reform_simulation_run_id + assert len(path.simulation_calls()) == 1 + + +def test_a_labour_supply_report_calculates_both_runs_again_with_its_columns() -> None: + path = _Path() + _, first = path.submit() + path.runs.succeed(first.baseline_simulation_run_id) + path.dispatcher.requests.clear() + + _, report_run = path.submit(reform=SimulationChoice(policy_id=LABOR_SUPPLY_POLICY)) + + assert report_run.baseline_simulation_run_id != first.baseline_simulation_run_id + calls = path.simulation_calls() + assert len(calls) == 2 + assert all(call.labor_supply_response for call in calls) + + +def test_a_policy_its_catalog_version_cannot_run_fails_its_run_clearly() -> None: + path = _Path() + + _, report_run = path.submit(reform=SimulationChoice(policy_id=MISSING_POLICY)) + + run = path.runs.simulation_runs[report_run.reform_simulation_run_id] + assert run.status is RunStatus.FAILED + assert run.error_code == RunErrorCode.INVALID_INPUT.value + assert "gov.retired" in (run.error_message or "") + assert [call.simulation_run_id for call in path.simulation_calls()] == [ + report_run.baseline_simulation_run_id + ] + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"policyengine_version": "6.8.0"}, "no catalog for PolicyEngine.py 6.8.0"), + ({"region_code": "state/zz"}, "no region state/zz"), + ({"dataset_name": "populace_us_2019"}, "no dataset populace_us_2019"), + ({"options": {"include_cliffs": True}}, "take no options"), + ({"reform": SimulationChoice(policy_id=uuid4())}, "policy or dynamic"), + ], +) +def test_a_submission_that_cannot_run_is_refused_before_anything_starts( + changes, message +) -> None: + path = _Path() + + with pytest.raises(EconomyRequestError, match=message): + path.submit(**changes) + + assert path.reports.rows == {} + assert path.runs.simulation_runs == {} + assert path.dispatcher.requests == [] + + +def test_a_rerun_reuses_runs_unless_forced() -> None: + path = _Path() + report, first = path.submit() + + rerun = asyncio.run( + path.economy.run_report( + report.id, + policyengine_version="6.7.5", + trigger=RunTrigger.MANUAL, + force=False, + ) + ) + forced = asyncio.run( + path.economy.run_report( + report.id, + policyengine_version="6.7.5", + trigger=RunTrigger.MANUAL, + force=True, + ) + ) + + assert rerun.trigger is RunTrigger.MANUAL + assert rerun.baseline_simulation_run_id == first.baseline_simulation_run_id + assert forced.baseline_simulation_run_id != first.baseline_simulation_run_id + assert path.runs.simulation_runs[forced.baseline_simulation_run_id].attempt == 2 + assert path.runs.latest_report_run(report.id) == forced + + +def _migrated_report(path: _Path, reform_policy=REFORM_POLICY): + """Return a report migrated from v1 without a run.""" + baseline, reform = ( + path.simulations.find_or_create( + NewSimulation( + kind=SimulationKind.ECONOMY, + country="us", + policy_id=policy_id, + dynamic_id=None, + region_code="state/ca", + ) + ) + for policy_id in (None, reform_policy) + ) + return path.reports.create( + NewReport( + kind=ReportKind.ECONOMY, + country="us", + baseline_simulation_id=baseline.id, + reform_simulation_id=reform.id, + year=2026, + ) + ) + + +def _system_run(path: _Path, report_id): + return asyncio.run( + path.economy.run_report( + report_id, + policyengine_version="6.7.5", + trigger=RunTrigger.SYSTEM, + force=False, + ) + ) + + +def test_a_report_without_a_run_is_calculated_on_the_systems_trigger() -> None: + path = _Path() + report = _migrated_report(path) + + report_run = _system_run(path, report.id) + + assert report_run.trigger is RunTrigger.SYSTEM + assert len(path.simulation_calls()) == 2 + + +def test_a_system_run_of_a_report_that_has_a_run_returns_its_newest() -> None: + path = _Path() + report, first = path.submit() + path.dispatcher.requests.clear() + + report_run = _system_run(path, report.id) + + assert report_run == first + assert path.runs.report_runs(report.id) == [first] + assert path.dispatcher.requests == [] + + +class _RunsWhereAnotherFirstOpenWins(FakeRuns): + """Another first open of the report records its run just before this one. + + The other caller's report run uses ``winner_runs``, or, when they are not + set, the same simulation runs as this caller's. + """ + + winner_runs: tuple | None = None + + def create_first_report_run(self, new_report_run: NewReportRun): + if self.latest_report_run(new_report_run.report_id) is None: + baseline, reform = self.winner_runs or ( + new_report_run.baseline_simulation_run_id, + new_report_run.reform_simulation_run_id, + ) + self.create_report_run( + replace( + new_report_run, + baseline_simulation_run_id=baseline, + reform_simulation_run_id=reform, + ) + ) + return super().create_first_report_run(new_report_run) + + +def test_a_first_open_that_loses_still_starts_the_runs_it_created() -> None: + """The other caller reused this caller's new runs, so it will not start them.""" + path = _Path(runs=_RunsWhereAnotherFirstOpenWins()) + report = _migrated_report(path) + + report_run = _system_run(path, report.id) + + assert path.runs.report_runs(report.id) == [report_run] + calls = path.simulation_calls() + assert {call.simulation_run_id for call in calls} == { + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + } + + +def _labour_supply_report_another_first_open_wins(path: _Path, runs): + """Return a migrated labour supply report whose winning first open forced runs.""" + report = _migrated_report(path, reform_policy=LABOR_SUPPLY_POLICY) + report_record = path.reports.get(report.id) + runs.winner_runs = tuple( + runs.start_simulation_run( + NewSimulationRun( + simulation_id=simulation_id, + tax_benefit_model_version_id=US_VERSION.id, + year=2026, + trigger=RunTrigger.SYSTEM, + region_code="state/ca", + dataset_id=CALIFORNIA.dataset_id, + ), + force=True, + ).run.id + for simulation_id in ( + report_record.baseline_simulation_id, + report_record.reform_simulation_id, + ) + ) + return report + + +def test_a_first_open_that_loses_leaves_the_runs_it_cannot_use_pending() -> None: + """A labour supply report forces its own runs; the losing caller's are unused. + + They stay pending for the safety check, which fails a run nothing compares + and starts one that another report has reused since. + """ + runs = _RunsWhereAnotherFirstOpenWins() + path = _Path(runs=runs) + report = _labour_supply_report_another_first_open_wins(path, runs) + + report_run = _system_run(path, report.id) + + assert ( + report_run.baseline_simulation_run_id, + report_run.reform_simulation_run_id, + ) == runs.winner_runs + assert path.simulation_calls() == [] + unused = [ + run for run in runs.simulation_runs.values() if run.id not in runs.winner_runs + ] + assert len(unused) == 2 + assert all(run.status is RunStatus.PENDING for run in unused) + + +class _RunsWhereAnotherReportReusesTheLosersRun(_RunsWhereAnotherFirstOpenWins): + """Before the losing first open records its run, another report reuses one. + + Another report sharing the current-law baseline found the loser's new + attempt pending and recorded a report run comparing it, without starting + it, as a submission that reuses a pending run does. + """ + + other_report_id = None + + def create_first_report_run(self, new_report_run: NewReportRun): + if self.other_report_id is not None: + self.create_report_run( + replace( + new_report_run, + report_id=self.other_report_id, + reform_simulation_run_id=new_report_run.baseline_simulation_run_id, + trigger=RunTrigger.INITIAL, + ) + ) + self.other_report_id = None + return super().create_first_report_run(new_report_run) + + +def test_a_first_open_that_loses_never_fails_a_run_another_report_compares() -> None: + runs = _RunsWhereAnotherReportReusesTheLosersRun() + path = _Path(runs=runs) + report = _labour_supply_report_another_first_open_wins(path, runs) + runs.other_report_id = _migrated_report(path).id + + _system_run(path, report.id) + + reused = [ + run + for run in runs.simulation_runs.values() + if run.id not in runs.winner_runs + and runs.report_runs_using(run.id) + and any( + report_run.report_id != report.id + for report_run in runs.report_runs_using(run.id) + ) + ] + assert len(reused) == 1 + assert reused[0].status is RunStatus.PENDING + + +@pytest.mark.parametrize( + ("report_exists", "trigger", "error"), + [ + (False, RunTrigger.MANUAL, ReportNotFoundError), + (True, RunTrigger.INITIAL, EconomyRequestError), + ], +) +def test_a_rerun_needs_an_existing_report_and_a_rerun_trigger( + report_exists, trigger, error +) -> None: + path = _Path() + report, _ = path.submit() + + with pytest.raises(error): + asyncio.run( + path.economy.run_report( + report.id if report_exists else uuid4(), + policyengine_version="6.7.5", + trigger=trigger, + force=False, + ) + ) + + +def test_a_finished_run_starts_the_comparisons_waiting_for_it() -> None: + path = _Path() + _, report_run = path.submit() + path.runs.succeed(report_run.baseline_simulation_run_id) + asyncio.run( + path.economy.start_comparisons_after([report_run.baseline_simulation_run_id]) + ) + assert path.comparison_calls() == [] + + path.runs.succeed(report_run.reform_simulation_run_id) + asyncio.run( + path.economy.start_comparisons_after([report_run.reform_simulation_run_id]) + ) + + assert [call.report_run_id for call in path.comparison_calls()] == [report_run.id] + + +def test_a_comparison_whose_output_is_no_longer_offered_fails_clearly() -> None: + path = _Path() + _, report_run = path.submit() + path.runs.succeed(report_run.baseline_simulation_run_id) + path.runs.succeed(report_run.reform_simulation_run_id) + del path.runs.files[(report_run.reform_simulation_run_id, "simulation_output")] + + asyncio.run( + path.economy.start_comparisons_after([report_run.reform_simulation_run_id]) + ) + + comparison = path.runs.report_runs_by_id[report_run.id] + assert comparison.comparison_status is RunStatus.FAILED + assert comparison.comparison_error_code == RunErrorCode.RESULT_EXPIRED.value + assert path.comparison_calls() == [] + + +def _age(path: _Path, run_id, minutes: int) -> None: + run = path.runs.simulation_runs[run_id] + path.runs.simulation_runs[run_id] = replace( + run, created_at=run.created_at - timedelta(minutes=minutes) + ) + + +def test_the_safety_check_starts_comparisons_and_runs_left_waiting() -> None: + path = _Path() + _, compared = path.submit() + path.runs.succeed(compared.baseline_simulation_run_id) + path.runs.succeed(compared.reform_simulation_run_id) + path.dispatcher.requests.clear() + # A submitter that stopped after recording its runs left this one pending. + _, stranded = path.submit(reform=SimulationChoice(policy_id=LABOR_SUPPLY_POLICY)) + reform_id = stranded.reform_simulation_run_id + path.runs.simulation_runs[reform_id] = replace( + path.runs.simulation_runs[reform_id], + status=RunStatus.PENDING, + modal_call_id=None, + next_check_at=None, + ) + _age(path, reform_id, 11) + path.dispatcher.requests.clear() + + resumed = asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + + assert resumed.comparisons_started == 1 + assert resumed.runs_started == 1 + assert [call.report_run_id for call in path.comparison_calls()] == [compared.id] + (call,) = path.simulation_calls() + assert call.simulation_run_id == reform_id + assert call.labor_supply_response is True + + +def test_the_safety_check_leaves_a_run_its_submitter_is_still_starting() -> None: + path = _Path() + _, report_run = path.submit() + run_id = report_run.reform_simulation_run_id + path.runs.simulation_runs[run_id] = replace( + path.runs.simulation_runs[run_id], status=RunStatus.PENDING + ) + path.dispatcher.requests.clear() + + resumed = asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + + assert resumed.runs_started == 0 + assert path.dispatcher.requests == [] + + +def test_a_run_a_labour_supply_report_waits_for_keeps_its_columns() -> None: + path = _Path() + _, labour_supply = path.submit( + reform=SimulationChoice(policy_id=LABOR_SUPPLY_POLICY) + ) + baseline_id = labour_supply.baseline_simulation_run_id + # Its submitter stopped before starting the current-law run's call. + path.runs.simulation_runs[baseline_id] = replace( + path.runs.simulation_runs[baseline_id], + status=RunStatus.PENDING, + modal_call_id=None, + next_check_at=None, + ) + path.dispatcher.requests.clear() + + _, ordinary = path.submit(reform=SimulationChoice(policy_id=OTHER_POLICY)) + + # The ordinary report reuses the run but leaves starting it to its + # submitter, or to the safety check, which asks for the columns. + assert ordinary.baseline_simulation_run_id == baseline_id + assert baseline_id not in { + call.simulation_run_id for call in path.simulation_calls() + } + _age(path, baseline_id, 11) + asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + calls = {call.simulation_run_id: call for call in path.simulation_calls()} + assert calls[baseline_id].labor_supply_response is True + assert calls[ordinary.reform_simulation_run_id].labor_supply_response is False + + +def test_a_report_never_starts_a_pending_run_another_submitter_created() -> None: + path = _Path() + current_law = path.simulations.find_or_create( + NewSimulation(kind=SimulationKind.ECONOMY, country="us", region_code="state/ca") + ) + # A labour supply report has created its forced run and not yet recorded + # its report run or started the run's call. + forced = path.runs.start_simulation_run( + NewSimulationRun( + simulation_id=current_law.id, + tax_benefit_model_version_id=US_VERSION.id, + year=2026, + trigger=RunTrigger.INITIAL, + region_code="state/ca", + dataset_id=CALIFORNIA.dataset_id, + ), + force=True, + ).run + + _, ordinary = path.submit(reform=SimulationChoice(policy_id=OTHER_POLICY)) + + assert ordinary.baseline_simulation_run_id == forced.id + assert [call.simulation_run_id for call in path.simulation_calls()] == [ + ordinary.reform_simulation_run_id + ] + assert path.runs.simulation_runs[forced.id].status is RunStatus.PENDING + + +def _strand(path: _Path, run_id) -> None: + path.runs.simulation_runs[run_id] = replace( + path.runs.simulation_runs[run_id], + status=RunStatus.PENDING, + modal_call_id=None, + next_check_at=None, + ) + _age(path, run_id, 11) + + +def test_one_run_that_cannot_resume_does_not_stop_the_others() -> None: + path = _Path() + _, first = path.submit() + _, second = path.submit(reform=SimulationChoice(policy_id=OTHER_POLICY)) + for run_id in (first.reform_simulation_run_id, second.reform_simulation_run_id): + _strand(path, run_id) + # The first reform's region has since left the catalog. + first_reform = path.simulations.rows[ + path.runs.simulation_runs[first.reform_simulation_run_id].simulation_id + ] + path.simulations.rows[first_reform.id] = replace( + first_reform, region_code="state/zz" + ) + path.dispatcher.requests.clear() + + resumed = asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + + assert resumed.runs_started == 1 + assert [call.simulation_run_id for call in path.simulation_calls()] == [ + second.reform_simulation_run_id + ] + unresolvable = path.runs.simulation_runs[first.reform_simulation_run_id] + assert unresolvable.status is RunStatus.FAILED + assert unresolvable.error_code == RunErrorCode.INVALID_INPUT.value + + +def test_a_pending_run_no_report_compares_is_failed_not_kept_waiting() -> None: + path = _Path() + _, report_run = path.submit() + run_id = report_run.reform_simulation_run_id + _strand(path, run_id) + path.runs.report_runs_by_id.clear() + path.dispatcher.requests.clear() + + resumed = asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + + assert resumed.runs_started == 0 + run = path.runs.simulation_runs[run_id] + assert run.status is RunStatus.FAILED + assert run.error_code == RunErrorCode.DISPATCH_ABANDONED.value + assert path.dispatcher.requests == [] + + +def test_a_pending_household_run_is_left_to_the_household_path() -> None: + path = _Path() + _, report_run = path.submit() + run_id = report_run.reform_simulation_run_id + _strand(path, run_id) + path.runs.simulation_runs[run_id] = replace( + path.runs.simulation_runs[run_id], simulation_kind=SimulationKind.HOUSEHOLD + ) + path.dispatcher.requests.clear() + + resumed = asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + + assert resumed.runs_started == 0 + assert path.runs.simulation_runs[run_id].status is RunStatus.PENDING + + +def test_only_comparisons_whose_call_started_are_counted() -> None: + path = _Path() + _, report_run = path.submit() + path.runs.succeed(report_run.baseline_simulation_run_id) + path.runs.succeed(report_run.reform_simulation_run_id) + del path.runs.files[(report_run.reform_simulation_run_id, "simulation_output")] + + resumed = asyncio.run(path.economy.resume_waiting_work(now=path.runs.clock())) + + assert resumed.comparisons_started == 0 + assert path.runs.report_runs_by_id[report_run.id].comparison_status is ( + RunStatus.FAILED + ) + + +def test_catalog_rows_a_call_cannot_carry_refuse_the_submission_first() -> None: + path = _Path() + path.catalog.populations["state/ca"] = replace(CALIFORNIA, state_code="california") + + with pytest.raises(EconomyRequestError, match="state/ca"): + path.submit() + + assert path.reports.rows == {} + assert path.runs.simulation_runs == {} + + +def test_a_household_report_cannot_be_run_on_the_economy_path() -> None: + path = _Path() + report, _ = path.submit() + path.reports.rows[report.id] = replace(report, kind=ReportKind.HOUSEHOLD) + + with pytest.raises(ReportNotFoundError): + asyncio.run( + path.economy.run_report( + report.id, + policyengine_version="6.7.5", + trigger=RunTrigger.MANUAL, + force=False, + ) + ) diff --git a/projects/policyengine-simulation-entry/tests/test_economy_routes.py b/projects/policyengine-simulation-entry/tests/test_economy_routes.py new file mode 100644 index 000000000..f87931ff1 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_economy_routes.py @@ -0,0 +1,343 @@ +"""The v2 economy report routes: submit, read, re-run, list runs, look up.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path +from uuid import uuid4 + +import pytest +from conftest import FakeBackend, make_settings +from economy_fakes import CTC_REFORM, US_VERSION, FakeCatalog, FakeReports, FakeRuns +from economy_fakes import FakeSimulations as FakeSimulationStore +from fastapi.testclient import TestClient +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_entry.app import RunServices, create_app +from policyengine_simulation_entry.economy_reports import EconomyReports +from policyengine_simulation_entry.economy_routes import ( + EconomyReportReads, + EconomyRouteServices, +) +from policyengine_simulation_entry.economy_schemas import ( + EconomyErrorResponse, + EconomyRefusalResponse, +) +from policyengine_simulation_entry.run_dispatch import RunDispatchService +from policyengine_simulation_persistence import RunErrorCode, RunStatus +from run_fakes import FakeDispatcher + +RESULT = json.loads( + (Path(__file__).parent / "fixtures" / "economy_result.json").read_text() +) +REFORM_POLICY = uuid4() +SUBMISSION = { + "country": "us", + "policyengine_version": "6.7.5", + "year": 2026, + "region_code": "state/ca", + "baseline": {}, + "reform": {"policy_id": str(REFORM_POLICY)}, +} + + +class _Results: + """Return the stored result of a succeeded comparison.""" + + def __init__(self) -> None: + self.read: list[tuple] = [] + + def read_result(self, report_run_id, catalog_version_id, country): + self.read.append((report_run_id, catalog_version_id, country)) + return SingleYearMacroOutput.model_validate(RESULT) + + +class _Path: + def __init__(self) -> None: + self.catalog = FakeCatalog() + self.catalog.changes[REFORM_POLICY] = CTC_REFORM + self.simulations = FakeSimulationStore() + self.simulations.policies_of_country["us"].add(REFORM_POLICY) + self.reports = FakeReports() + self.runs = FakeRuns() + self.dispatcher = FakeDispatcher() + self.results = _Results() + self.backend = FakeBackend() + self.services = services = EconomyRouteServices( + reports=EconomyReports( + simulations=self.simulations, + reports=self.reports, + runs=self.runs, + catalog=self.catalog, + transitions=self.runs, + dispatch=RunDispatchService( + transitions=self.runs, + dispatcher=self.dispatcher, + clock=self.runs.clock, + ), + artifact_environment="test", + ), + reads=EconomyReportReads( + simulations=self.simulations, + reports=self.reports, + runs=self.runs, + catalog=self.catalog, + results=self.results, + ), + ) + self.client = TestClient( + create_app( + settings=make_settings(), + backend=self.backend, + auth_dependency=lambda: None, + run_services=RunServices(economy=services), + ) + ) + + def submit(self) -> dict: + response = self.client.post("/v2/economy-reports", json=SUBMISSION) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.fixture +def economy_path(): + path = _Path() + with path.client: + yield path + + +def test_a_submitted_report_is_running_its_simulations(economy_path) -> None: + report = economy_path.submit() + + assert report["country"] == "us" + assert report["year"] == 2026 + assert report["status"] == "running" + assert report["result"] is None + assert report["reform_simulation"]["policy_id"] == str(REFORM_POLICY) + assert report["baseline_simulation"]["region_code"] == "state/ca" + run = report["run"] + assert run["trigger"] == "initial" + assert run["policyengine_version"] == "6.7.5" + assert run["catalog_version_id"] == str(US_VERSION.id) + assert run["stage"] == "simulating" + assert run["baseline_run"]["status"] == "running" + assert run["comparison"]["status"] == "pending" + assert economy_path.backend.requests == [] + + +def test_a_report_is_read_back_with_its_result_once_compared(economy_path) -> None: + report = economy_path.submit() + runs = economy_path.runs + report_run_id = report["run"]["id"] + for side in ("baseline_run", "reform_run"): + runs.succeed(runs.simulation_runs[_uuid(report["run"][side]["id"])].id) + record = runs.report_runs_by_id[_uuid(report_run_id)] + + comparing = economy_path.client.get(f"/v2/economy-reports/{report['id']}") + runs.report_runs_by_id[record.id] = replace( + record, comparison_status=RunStatus.SUCCEEDED, comparison_modal_call_id="fc-c" + ) + finished = economy_path.client.get(f"/v2/economy-reports/{report['id']}") + + assert comparing.json()["run"]["stage"] == "outputs" + assert comparing.json()["result"] is None + body = finished.json() + assert body["status"] == "succeeded" + assert body["run"]["stage"] is None + assert body["result"] == SingleYearMacroOutput.model_validate(RESULT).model_dump( + mode="json" + ) + assert economy_path.results.read == [(record.id, US_VERSION.id, "us")] + + +class _UnreadableResults: + """A stored result naming an area the catalog version no longer has.""" + + def read_result(self, report_run_id, catalog_version_id, country): + msg = "the catalog has no constituency E14009999" + raise LookupError(msg) + + +def test_a_stored_result_that_cannot_be_rebuilt_is_a_declared_500( + economy_path, +) -> None: + report = economy_path.submit() + runs = economy_path.runs + for side in ("baseline_run", "reform_run"): + runs.succeed(runs.simulation_runs[_uuid(report["run"][side]["id"])].id) + record = runs.report_runs_by_id[_uuid(report["run"]["id"])] + runs.report_runs_by_id[record.id] = replace( + record, comparison_status=RunStatus.SUCCEEDED, comparison_modal_call_id="fc-c" + ) + economy_path.services.reads.results = _UnreadableResults() + + by_id = economy_path.client.get(f"/v2/economy-reports/{report['id']}") + + assert by_id.status_code == 500 + assert EconomyErrorResponse.model_validate(by_id.json()).detail == ( + "The report's stored result cannot be read back." + ) + + +def test_a_failed_simulation_run_fails_the_report_with_its_error(economy_path) -> None: + report = economy_path.submit() + runs = economy_path.runs + reform_id = _uuid(report["run"]["reform_run"]["id"]) + runs.simulation_runs[reform_id] = replace( + runs.simulation_runs[reform_id], + status=RunStatus.FAILED, + error_code=RunErrorCode.WORKER_ERROR.value, + error_message="The Modal call raised ReportExecutionError.", + ) + + body = economy_path.client.get(f"/v2/economy-reports/{report['id']}").json() + + assert body["status"] == "failed" + assert body["run"]["error"] == { + "code": "worker_error", + "message": "The Modal call raised ReportExecutionError.", + } + + +def test_a_report_is_run_again_and_its_runs_are_listed_newest_first( + economy_path, +) -> None: + report = economy_path.submit() + + rerun = economy_path.client.post( + f"/v2/economy-reports/{report['id']}/runs", + json={"policyengine_version": "6.7.5", "trigger": "manual", "force": True}, + ) + listed = economy_path.client.get(f"/v2/economy-reports/{report['id']}/runs") + + assert rerun.status_code == 201 + assert rerun.json()["trigger"] == "manual" + assert rerun.json()["baseline_run"]["attempt"] == 2 + assert [run["id"] for run in listed.json()["runs"]] == [ + rerun.json()["id"], + report["run"]["id"], + ] + + +def test_a_report_migrated_without_a_run_reads_as_not_run(economy_path) -> None: + report = economy_path.submit() + economy_path.runs.report_runs_by_id.clear() + stored = economy_path.reports.rows[_uuid(report["id"])] + economy_path.reports.rows[stored.id] = replace(stored, legacy_report_id=4321) + + by_id = economy_path.client.get(f"/v2/economy-reports/{report['id']}") + by_legacy = economy_path.client.get("/v2/economy-reports/by-legacy-id/4321") + + assert by_id.json()["status"] == "not_run" + assert by_id.json()["run"] is None + assert by_legacy.json()["id"] == report["id"] + assert by_legacy.json()["legacy_report_id"] == 4321 + + +def test_a_simulation_is_read_by_id(economy_path) -> None: + report = economy_path.submit() + simulation_id = report["reform_simulation"]["id"] + + found = economy_path.client.get(f"/v2/simulations/{simulation_id}") + missing = economy_path.client.get(f"/v2/simulations/{uuid4()}") + + assert found.status_code == 200 + assert found.json()["kind"] == "economy" + assert found.json()["policy_id"] == str(REFORM_POLICY) + assert missing.status_code == 404 + + +@pytest.mark.parametrize( + ("method", "path", "body", "status"), + [ + ( + "post", + "/v2/economy-reports", + {**SUBMISSION, "policyengine_version": "9.9.9"}, + 422, + ), + ("post", "/v2/economy-reports", {**SUBMISSION, "options": {"spm": {}}}, 422), + ("post", "/v2/economy-reports", {**SUBMISSION, "country": "ca"}, 422), + ("get", f"/v2/economy-reports/{uuid4()}", None, 404), + ("get", "/v2/economy-reports/by-legacy-id/99", None, 404), + ( + "post", + f"/v2/economy-reports/{uuid4()}/runs", + {"policyengine_version": "6.7.5", "trigger": "manual"}, + 404, + ), + ("get", f"/v2/economy-reports/{uuid4()}/runs", None, 404), + ], +) +def test_a_request_sim_cannot_serve_is_refused( + economy_path, method, path, body, status +) -> None: + response = getattr(economy_path.client, method)( + path, **({} if body is None else {"json": body}) + ) + + assert response.status_code == status + assert "detail" in response.json() + + +@pytest.mark.parametrize( + ("route_request", "status", "detail_kind"), + [ + ( + ("post", "/v2/economy-reports", {**SUBMISSION, "options": {"spm": {}}}), + 422, + str, + ), + (("post", "/v2/economy-reports", {**SUBMISSION, "country": "ca"}), 422, list), + (("get", "/v2/economy-reports/not-a-report-id", None), 422, list), + (("get", f"/v2/economy-reports/{uuid4()}", None), 404, str), + (("get", f"/v2/simulations/{uuid4()}", None), 404, str), + ], +) +def test_each_error_answer_has_the_body_the_routes_declare( + economy_path, route_request, status, detail_kind +) -> None: + method, path, body = route_request + response = getattr(economy_path.client, method)( + path, **({} if body is None else {"json": body}) + ) + + assert response.status_code == status + declared = EconomyRefusalResponse if status == 422 else EconomyErrorResponse + answer = declared.model_validate(response.json()) + assert isinstance(answer.detail, detail_kind) + + +def test_the_routes_answer_503_without_a_simulation_database() -> None: + with TestClient( + create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + ) + ) as client: + response = client.post("/v2/economy-reports", json=SUBMISSION) + + assert response.status_code == 503 + assert isinstance(EconomyErrorResponse.model_validate(response.json()).detail, str) + + +def _uuid(value: str): + from uuid import UUID + + return UUID(value) + + +def test_a_household_report_is_not_an_economy_report(economy_path) -> None: + report = economy_path.submit() + stored = economy_path.reports.rows[_uuid(report["id"])] + from policyengine_simulation_persistence import ReportKind + + economy_path.reports.rows[stored.id] = replace(stored, kind=ReportKind.HOUSEHOLD) + + read = economy_path.client.get(f"/v2/economy-reports/{report['id']}") + runs = economy_path.client.get(f"/v2/economy-reports/{report['id']}/runs") + + assert read.status_code == 404 + assert runs.status_code == 404 diff --git a/projects/policyengine-simulation-entry/tests/test_economy_staging_check_deployment.py b/projects/policyengine-simulation-entry/tests/test_economy_staging_check_deployment.py new file mode 100644 index 000000000..427093b40 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_economy_staging_check_deployment.py @@ -0,0 +1,132 @@ +"""The staging check of a small v2 economy report, after each staging promotion.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import yaml + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +REUSABLE_DEPLOY_WORKFLOW = ( + REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" +) +CHECK = "authenticated_tests/test_staging_economy_report.py" + + +def _job() -> dict: + jobs = yaml.safe_load(REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8"))["jobs"] + return jobs["economy_staging_check"] + + +def test_the_check_runs_on_staging_once_the_tested_revision_is_promoted() -> None: + job = _job() + + assert set(job["needs"]) >= {"prepare", "deploy_entrypoint", "promote_entrypoint"} + condition = " ".join(job["if"].split()) + assert "inputs.deployment_environment == 'staging'" in condition + assert "needs.promote_entrypoint.result == 'success'" in condition + assert job["environment"] == "${{ inputs.release_environment }}" + + +def test_the_check_submits_to_the_stable_url_with_the_test_client() -> None: + environment = _job()["env"] + + assert environment["SIMULATION_ENTRYPOINT_TEST_BASE_URL"] == ( + "${{ needs.deploy_entrypoint.outputs.stable_url }}" + ) + assert environment["SIMULATION_STAGING_ECONOMY_POLICYENGINE_VERSION"] == ( + "${{ needs.prepare.outputs.policyengine_version }}" + ) + assert environment["SIMULATION_STAGING_ECONOMY_CHECK"] == "1" + assert { + name: value + for name, value in environment.items() + if name.startswith("SIMULATION_ENTRYPOINT_TEST_AUTH_") + } == { + "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 }}" + ), + } + + +def test_the_check_runs_only_its_own_test_and_never_stops_a_deployment() -> None: + job = _job() + + # The v2 economy path serves no user yet; its failure is reported in the + # run without holding back the production deployment. + assert job["continue-on-error"] is True + assert job["timeout-minutes"] <= 30 + commands = [step["run"] for step in job["steps"] if "run" in step] + assert f"uv run --frozen pytest {CHECK} -v" in commands + + +def test_the_check_is_skipped_wherever_it_is_not_asked_for() -> None: + """The deployed-auth runs collect the whole directory; only this job asks.""" + environment = { + name: value + for name, value in os.environ.items() + if name != "SIMULATION_STAGING_ECONOMY_CHECK" + } + + result = subprocess.run( + [sys.executable, "-m", "pytest", CHECK, "-q", "-rs", "-p", "no:cacheprovider"], + cwd=REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry", + env=environment, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + assert "1 skipped" in result.stdout + + +def test_the_staging_deployment_promotes_so_the_check_runs() -> None: + """The check runs after the promotion, which every staging caller asks for.""" + for workflow in ("simulation-deploy.yml", "stage12-deploy.yml"): + path = REPOSITORY_ROOT / ".github" / "workflows" / workflow + jobs = yaml.safe_load(path.read_text(encoding="utf-8"))["jobs"] + staging = [ + job + for job in jobs.values() + if job.get("with", {}).get("deployment_environment") == "staging" + ] + assert staging, workflow + assert all(job["with"]["promote_entrypoint"] is True for job in staging) + + +def _staging_check_module(): + import importlib.util + + path = REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / CHECK + spec = importlib.util.spec_from_file_location("staging_economy_check", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_the_check_runs_again_only_when_the_submission_reused_finished_runs() -> None: + check = _staging_check_module() + + def submitted(status: str) -> dict: + return {"run": {"baseline_run": {"status": status}}} + + # A submission that calculates its own run already uses this + # deployment's functions; one that reused finished runs does not. + assert check.needs_fresh_run(submitted("running")) is False + assert check.needs_fresh_run(submitted("pending")) is False + assert check.needs_fresh_run(submitted("succeeded")) is True 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..7f4c542ae --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_modal_identity.py @@ -0,0 +1,185 @@ +"""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()) + + +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()) diff --git a/projects/policyengine-simulation-entry/tests/test_modal_token_deployment.py b/projects/policyengine-simulation-entry/tests/test_modal_token_deployment.py new file mode 100644 index 000000000..24563081a --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_modal_token_deployment.py @@ -0,0 +1,88 @@ +"""The entrypoint's Modal token, and no CPU kept outside requests. + +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 + +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_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" 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 }}" + ) diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 060b6bb8c..69ebee8ee 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -3,7 +3,7 @@ import json from copy import deepcopy from pathlib import Path -from typing import Literal, TypedDict, cast +from typing import Any, Literal, TypedDict, cast from policyengine_simulation_entry.app import create_app @@ -37,7 +37,31 @@ class OpenAPIDocument(TypedDict): ("POST", "/ping"), ("POST", "/simulate/economy/budget-window"), ("POST", "/simulate/economy/comparison"), + ("GET", "/v2/economy-reports/by-legacy-id/{legacy_report_id}"), + ("GET", "/v2/economy-reports/{report_id}"), + ("GET", "/v2/economy-reports/{report_id}/runs"), + ("GET", "/v2/simulations/{simulation_id}"), + ("POST", "/v2/economy-reports"), + ("POST", "/v2/economy-reports/{report_id}/runs"), } +# The v2 economy report routes are the entrypoint's own; the old gateway has +# none of them or their bodies. +V2_SCHEMAS = ( + "ComparisonResponse", + "EconomyErrorResponse", + "EconomyRefusalResponse", + "EconomyRequestFieldError", + "EconomyReportResponse", + "EconomyReportRunRequest", + "EconomyReportRunResponse", + "EconomyReportRunsResponse", + "EconomyReportSubmissionRequest", + "ReportStatus", + "RunErrorResponse", + "SimulationChoiceRequest", + "SimulationResponse", + "SimulationRunResponse", +) def methods(spec: OpenAPIDocument) -> set[tuple[str, str]]: @@ -62,6 +86,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 path in [path for path in paths if path.startswith("/v2/")]: + paths.pop(path) for operations in paths.values(): for method, operation in operations.items(): if method.lower() in {"get", "post", "put", "patch", "delete"}: @@ -122,6 +148,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,11 +203,88 @@ 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") + for schema in V2_SCHEMAS: + cloud_run_schemas.pop(schema) 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_v2_economy_routes_are_public_for_the_generated_client(): + spec = cast("OpenAPIDocument", create_app().openapi()) + ids = operation_ids(spec) + + assert ids[("POST", "/v2/economy-reports")] == "submit_economy_report" + assert ids[("GET", "/v2/economy-reports/{report_id}")] == "get_economy_report" + assert ids[("POST", "/v2/economy-reports/{report_id}/runs")] == ( + "run_economy_report" + ) + assert set(V2_SCHEMAS) <= set(spec["components"]["schemas"]) + + +V2_ERROR_ANSWERS = { + ("POST", "/v2/economy-reports"): { + "422": "EconomyRefusalResponse", + "503": "EconomyErrorResponse", + }, + **{ + operation: { + "404": "EconomyErrorResponse", + "422": "EconomyRefusalResponse", + "503": "EconomyErrorResponse", + } + for operation in ( + ("GET", "/v2/economy-reports/{report_id}"), + ("GET", "/v2/economy-reports/by-legacy-id/{legacy_report_id}"), + ("POST", "/v2/economy-reports/{report_id}/runs"), + ("GET", "/v2/economy-reports/{report_id}/runs"), + ("GET", "/v2/simulations/{simulation_id}"), + ) + }, +} +# The reads that rebuild a stored result also answer 500 when it cannot be. +for _result_read in ( + ("GET", "/v2/economy-reports/{report_id}"), + ("GET", "/v2/economy-reports/by-legacy-id/{legacy_report_id}"), +): + V2_ERROR_ANSWERS[_result_read] = { + **V2_ERROR_ANSWERS[_result_read], + "500": "EconomyErrorResponse", + } + + +def test_v2_routes_declare_each_error_answer_with_its_body(): + """The generated client parses sim's own refusals, not only FastAPI's.""" + spec = cast("dict[str, Any]", create_app().openapi()) + + declared = { + (method, path): { + status: answer["content"]["application/json"]["schema"]["$ref"].rpartition( + "/" + )[2] + for status, answer in spec["paths"][path][method.lower()][ + "responses" + ].items() + if not status.startswith("2") + } + for method, path in V2_ERROR_ANSWERS + } + + assert declared == V2_ERROR_ANSWERS + refusal_detail = spec["components"]["schemas"]["EconomyRefusalResponse"][ + "properties" + ]["detail"] + assert refusal_detail["anyOf"] == [ + {"type": "string"}, + { + "items": {"$ref": "#/components/schemas/EconomyRequestFieldError"}, + "type": "array", + }, + ] 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..a90f420bc --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_run_dispatch.py @@ -0,0 +1,629 @@ +"""Starting a run's Modal call exactly once, and reading Modal's answers.""" + +from __future__ import annotations + +import asyncio +import builtins + +import grpclib.const +import grpclib.exceptions +import modal +import pytest +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 +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 RunError, 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 + # 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 +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): + 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 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 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 + ) + + +@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] + + 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 _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 = [] + + 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 +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(caplog) -> 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 + assert [record.message for record in caplog.records] == [ + "report_run_comparison_dispatch_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.OutputExpiredError(), RunErrorCode.RESULT_EXPIRED), + (modal.exception.NotFoundError("gone"), RunErrorCode.CALL_NOT_FOUND), + ], + ids=["expired", "not-found"], +) +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 + + +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( + "raised", + [ + 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"), + RuntimeError("an unexpected client problem"), + ], + ids=[ + "connection", + "service", + "permission", + "version", + "client-closed", + "stream-terminated", + "grpc", + "other-client-error", + ], +) +def test_a_modal_problem_is_retried_later(monkeypatch, raised) -> None: + with pytest.raises(CallInspectionError): + _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.") + ) + + +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)], + 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 = {} + + 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"}, + } 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..a2408ea8a --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_run_recording.py @@ -0,0 +1,403 @@ +"""Recording each Modal call's outcome on its runs, one pass at a time.""" + +from __future__ import annotations + +import asyncio +import builtins +from datetime import timedelta + +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 RunServices, create_app +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallInspectionError, + CallSucceeded, +) +from policyengine_simulation_entry.run_recording import ( + CHECK_LEASE, + RecordingPass, + ResultRecorders, + RunRecorder, +) +from policyengine_simulation_persistence import ( + HEAD_REVISION, + ReportKind, + RunError, + RunErrorCode, + RunStatus, + SimulationKind, +) +from policyengine_simulation_persistence.engine import MAX_OVERFLOW, POOL_SIZE +from pydantic import BaseModel +from run_fakes import FakeInspector, FakeRunTransitions, ManualClock + + +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.""" + + 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, + runs=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()) + + +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") + + 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() + 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: + 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_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}) + + recording = _run(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() + CHECK_LEASE + + +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() + CHECK_LEASE + + +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 + + +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 + + +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 + + +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") + 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, error): + if modal_call_id == "fc-broken": + msg = "connection lost" + raise ConnectionError(msg) + return fail_call(modal_call_id, error) + + transitions.fail_simulation_call = 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 _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, inspector, clock = _recorder() + (run_id,) = transitions.running_call("fc-broken") + inspector.outcomes["fc-broken"] = CallFailed(WORKER_ERROR) + + def raise_a_group(modal_call_id, error): + message = "writes" + raise builtins.BaseExceptionGroup(message, [_NotAnException()]) + + transitions.fail_simulation_call = 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, inspector, _ = _recorder() + transitions.running_call("fc-1") + inspector.outcomes["fc-1"] = CallFailed(WORKER_ERROR) + + def interrupted(modal_call_id, error): + raise KeyboardInterrupt + + transitions.fail_simulation_call = interrupted # type: ignore[method-assign] + + with pytest.raises(KeyboardInterrupt): + _run(recorder) + + +class _DatabaseThatOnlyReadsItsRevision: + async def read_revision(self): + return HEAD_REVISION + + def dispose(self) -> None: + return None + + +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, + run_services=RunServices(database=_DatabaseThatOnlyReadsItsRevision()), + ) + + with TestClient(app) as client: + assert client.get("/health").status_code == 200 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..f04c784ac --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_safety_check.py @@ -0,0 +1,214 @@ +"""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 + + +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 + + telemetry = telemetry or _Telemetry() + status = safety_check.main( + environment=environment, + build_recorder=build, + start_observability=telemetry.start, + ) + 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" + ] + # 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: + 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 + + +class _Waiting: + """Stands in for the economy path's waiting work.""" + + def __init__(self) -> None: + self.resumed = 0 + + async def resume_waiting_work(self, *, now): + del now + self.resumed += 1 + return safety_check.ResumedWork(comparisons_started=2, runs_started=1) + + +def test_a_pass_also_starts_the_economy_work_left_waiting(caplog) -> None: + caplog.set_level("INFO") + waiting = _Waiting() + recorder = safety_check.SafetyCheckPass(_OnePass(RecordingPass(checked=1)), waiting) + + status, _ = _run({"SIMULATION_DATABASE_URL": DATABASE_URL}, recorder) + + assert status == 0 + assert waiting.resumed == 1 + (record,) = [r for r in caplog.records if r.getMessage() == "safety_check_pass"] + assert record.policyengine_attributes["comparisons_started"] == 2 + assert record.policyengine_attributes["runs_started"] == 1 + + +def test_a_pass_without_the_modal_environment_only_records() -> None: + inner = _OnePass(RecordingPass(checked=1)) + recorder = safety_check.SafetyCheckPass(inner, None) + + status, _ = _run({"SIMULATION_DATABASE_URL": DATABASE_URL}, recorder) + + assert status == 0 + assert inner.passes == 1 + + +def test_the_job_starts_economy_calls_in_its_own_modal_environment() -> None: + environment = { + "SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT": "staging", + "APP_ENVIRONMENT": "Staging", + } + + configured = safety_check._database_recorder(DATABASE_URL, environment=environment) + unconfigured = safety_check._database_recorder(DATABASE_URL, environment={}) + + assert configured.waiting is not None + assert configured.waiting.artifact_environment == "staging" + assert configured.waiting.dispatch.dispatcher._environment == "staging" + assert unconfigured.waiting is None + + +def test_the_default_pass_reads_the_environment_main_was_given(monkeypatch) -> None: + built_with = [] + recorder = _OnePass(RecordingPass(checked=1)) + + def build(database_url, *, environment): + built_with.append((database_url, environment)) + return recorder + + monkeypatch.setattr(safety_check, "_database_recorder", build) + environment = {"SIMULATION_DATABASE_URL": DATABASE_URL, "APP_ENVIRONMENT": "x"} + + status = safety_check.main( + environment=environment, start_observability=_Telemetry().start + ) + + assert status == 0 + assert built_with == [(DATABASE_URL, environment)] 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..8fdb72c01 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py @@ -0,0 +1,364 @@ +"""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": "SIMULATION_CALL_REPORT_MODAL_WORKSPACE_ID", + "value": "ac-workspace0001", + }, + { + "name": "SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT", + "value": "main", + }, + {"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")) + # The Modal environment is where the job starts the economy calls and + # comparisons that are waiting. + assert environment == { + "APP_ENVIRONMENT": "production", + "SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT": "main", + "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 }}" + ) + + +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" 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..c5fd00f91 --- /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 RunServices, 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, + run_services=RunServices(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/tests/test_simulation_database_deployment.py b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py new file mode 100644 index 000000000..ee6486c0e --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_simulation_database_deployment.py @@ -0,0 +1,442 @@ +"""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 +import yaml +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, + REUSABLE_DEPLOY_WORKFLOW, + SMOKE_SCRIPT, + evaluate_github_expression, + smoke_test_env, + workflow_jobs, + write_fake_smoke_curl, +) +from workflow_script_support import ( + REPOSITORY_ROOT, + 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 }}", + "SIMULATION_MIGRATION_MODE": ( + "${{ github.ref == 'refs/heads/main' && 'upgrade' || 'verify' }}" + ), + } + assert "env" not in migration + 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 = [ + (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 == [ + ( + "simulation-deploy.reusable.yml", + MIGRATION_JOB, + "Apply simulation schema migrations", + ) + ] + + +def test_the_deploy_workflow_follows_the_migration_wiring(): + 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(): + """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 + + +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_migration_script(fakes) + + assert result.returncode == 0, result.stderr + assert fakes.calls("uv") == [ + [*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_migration_script(fakes, 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_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 ----------------------------------------- + + +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 _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) + + 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/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/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index c3bfa7d53..594f94e69 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,32 @@ requires-dist = [ ] provides-extras = ["test", "build"] +[[package]] +name = "policyengine-simulation-persistence" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-persistence" } +dependencies = [ + { name = "policyengine-simulation-contract" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "sqlalchemy" }, +] + +[package.metadata] +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 = "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" }, + { 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/projects/policyengine-simulation-executor/fixtures/fake_modal.py b/projects/policyengine-simulation-executor/fixtures/fake_modal.py index bc64d36f5..d203c0d8f 100644 --- a/projects/policyengine-simulation-executor/fixtures/fake_modal.py +++ b/projects/policyengine-simulation-executor/fixtures/fake_modal.py @@ -102,6 +102,16 @@ def from_name(*args: object, **kwargs: object) -> dict[str, object]: return {"args": args, "kwargs": kwargs} +class InputCancellation(BaseException): + """Stand-in for ``modal.exception.InputCancellation``. + + Modal raises it in a running synchronous input that is cancelled. It + subclasses ``BaseException`` so that ``except Exception`` does not catch + it. The project's ``src/modal`` package shadows the installed Modal package + in unit tests, so the real class cannot be imported there. + """ + + class FakeApp: """Stand-in for ``modal.App`` that records ``function`` decorator options.""" 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/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 92642d673..2b08e0aab 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -11,9 +11,18 @@ import os from pathlib import Path from typing import TYPE_CHECKING +from uuid import UUID +from policyengine_simulation_contract.economy_runs import ( + ECONOMY_SIMULATION_FUNCTIONS, +) +from policyengine_simulation_contract.stage12_execution import ReportResultDelivery from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_executor import stage12_runtime +from policyengine_simulation_executor.economy_runs import ( + comparison_call, + simulation_call, +) from policyengine_simulation_executor.hf_access_validation import ( HF_MODAL_SECRET_NAME, HF_RUNTIME_ENV_NAME, @@ -94,6 +103,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", @@ -300,6 +326,23 @@ def run_single_simulation_segment_us( runtime.shutdown() +def _returns_to_caller(context: dict) -> bool: + """Report whether a dispatch context asks for the report to return to its caller.""" + return context.get("result_delivery") == ReportResultDelivery.RETURN_TO_CALLER.value + + +def _simulation_operation_attributes(payload: dict, context: dict) -> dict[str, object]: + """Return a worker operation's attributes; a returned report's worker says so.""" + attributes: dict[str, object] = { + "runner_name": "stage12", + "simulation_role": payload.get("role"), + } + if _returns_to_caller(context): + # Telemetry tells a returned report's workers apart from Stage 12 runs. + attributes["result_delivery"] = ReportResultDelivery.RETURN_TO_CALLER.value + return attributes + + @app.function( image=us_worker_image, cpu=8.0, @@ -339,10 +382,7 @@ def segmented_calculator( result = None with runtime.operation( STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SIMULATION_EXECUTION), - attributes={ - "runner_name": "stage12", - "simulation_role": payload.get("role"), - }, + attributes=_simulation_operation_attributes(payload, context), remote_context=propagated, ): result = stage12_runtime.run_single_simulation( @@ -389,10 +429,7 @@ def run_single_simulation_uk( result = None with runtime.operation( STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SIMULATION_EXECUTION), - attributes={ - "runner_name": "stage12", - "simulation_role": payload.get("role"), - }, + attributes=_simulation_operation_attributes(payload, context), remote_context=propagated, ): result = stage12_runtime.run_single_simulation( @@ -411,6 +448,24 @@ def run_single_simulation_uk( runtime.shutdown() +def _coordinator_operation_attributes( + context: dict, + *, + returns_to_caller: bool, +) -> dict[str, str]: + """Return the coordinator operation's attributes; a returned report says so.""" + attributes = { + "runner_name": "stage12", + "execution_mode": ( + "shadow" if context.get("production_function_call_id") else "authoritative" + ), + } + if returns_to_caller: + # Telemetry tells returned reports apart from Stage 12 runs. + attributes["result_delivery"] = ReportResultDelivery.RETURN_TO_CALLER.value + return attributes + + @app.function( image=coordinator_image, cpu=2.0, @@ -425,16 +480,24 @@ def run_single_simulation_uk( def coordinate_report( payload: dict, context: dict, - parent: dict, + parent: dict | None = None, *, observability_context: dict | None = None, ) -> dict: - """Run one Stage 12 report: fan out both simulations, aggregate, and persist. + """Run one report: fan out both simulations, aggregate, and deliver the result. A ``production_function_call_id`` in ``context`` marks a shadow run whose result is compared with that production job's. A report another invocation already claimed returns a ``deduplicated`` marker instead. + + A ``result_delivery`` of ``return_to_caller`` in ``context`` takes no + ``parent`` and reads and writes no Stage 12 rows: the report returns a + ``ReportExecutionResult`` payload or raises ``ReportExecutionError``. """ + returns_to_caller = _returns_to_caller(context) + if returns_to_caller and parent is not None: + msg = "a report returned to its caller takes no Stage 12 parent record" + raise ValueError(msg) coordinator_invocation_id = modal.current_function_call_id() if coordinator_invocation_id is None: msg = "Modal coordinator invocation identifier is unavailable" @@ -450,34 +513,244 @@ def coordinate_report( if context.get("production_function_call_id") else STAGE12_CANONICAL_REPORT_STAGES ) + invocation = stage12_runtime.CoordinatorInvocation( + application_name=APP_NAME, + invocation_id=coordinator_invocation_id, + ) + dependencies = stage12_runtime.CoordinatorDependencies(runtime=runtime) try: propagated = normalize_observability_context(observability_context) result = None with runtime.operation( stage_plan.name(Stage.STAGE12_COORDINATOR_EXECUTION), - attributes={ - "runner_name": "stage12", - "execution_mode": ( - "shadow" - if context.get("production_function_call_id") - else "authoritative" - ), - }, + attributes=_coordinator_operation_attributes( + context, returns_to_caller=returns_to_caller + ), remote_context=propagated, ): - result = stage12_runtime.coordinate_report( - payload, - context, - parent, - invocation=stage12_runtime.CoordinatorInvocation( - application_name=APP_NAME, - invocation_id=coordinator_invocation_id, - ), - dependencies=stage12_runtime.CoordinatorDependencies(runtime=runtime), - ) + if returns_to_caller: + result = stage12_runtime.coordinate_report_for_caller( + payload, + context, + invocation=invocation, + dependencies=dependencies, + ) + else: + result = stage12_runtime.coordinate_report( + payload, + context, + parent, + invocation=invocation, + dependencies=dependencies, + ) if result is None: msg = "Stage 12 report coordinator returned no result" raise RuntimeError(msg) return result finally: runtime.shutdown() + + +# Reading two stored simulation outputs and aggregating them, with a margin. +ECONOMY_COMPARISON_TIMEOUT_SECONDS = 1800 + + +def start_call_report(report: dict[str, object]) -> object: + """Start ``report_call_ended`` for a call that ended (``reporting_call_end``).""" + return report_call_ended.spawn(report) + + +def _payload_run_id(payload: object, field: str) -> UUID | None: + """Return the run ID a call's payload names, or None when it names none. + + A payload that fails validation still reports its run's end, so sim + records the failure without waiting for its safety check. + """ + value = payload.get(field) if isinstance(payload, dict) else None + try: + return UUID(str(value)) + except ValueError: + return None + + +def _run_economy_simulation( + country: CountryId, + call: dict, + observability_context: dict | None, +) -> dict: + """Calculate one economy simulation run, then report that the call ended. + + Pre-emption (``KeyboardInterrupt``) and cancellation (Modal's + ``InputCancellation``) are not ``Exception``s: the call sends no report, + a segmented run cancels the segment calls it started, and Modal runs a + pre-empted input again under the same call ID. + """ + from src.modal.call_reports import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + reporting_call_end, + ) + + modal_environment = os.getenv("MODAL_ENVIRONMENT", "local") + runtime = init_process_observability( + service_name=f"policyengine-economy-{country}-worker", + service_role="economy_simulation_worker", + platform="modal", + environment=modal_environment, + ) + + def segmented_calculator( + simulation: PlannedSimulationExecutionInput, + cancellation_requested: Callable[[], bool], + ) -> SimulationCalculation: + return stage12_runtime.run_segmented_simulation( + simulation, + app_name=APP_NAME, + runtime=runtime, + cancellation_requested=cancellation_requested, + ) + + run_id = _payload_run_id(call, "simulation_run_id") + try: + with ( + reporting_call_end( + modal_call_id=modal.current_function_call_id(), + simulation_run_ids=None if run_id is None else [run_id], + start_report=start_call_report, + ), + runtime.operation( + STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SIMULATION_EXECUTION), + attributes={ + "runner_name": "stage12", + "result_delivery": ReportResultDelivery.RETURN_TO_CALLER.value, + "simulation_run_id": str(run_id), + }, + remote_context=normalize_observability_context(observability_context), + ), + ): + return simulation_call.run_economy_simulation( + call, + required_country=country, + invocation=simulation_call.EconomyRunInvocation( + application_name=APP_NAME, + modal_environment=modal_environment, + simulation_callable=ECONOMY_SIMULATION_FUNCTIONS[country], + ), + dependencies=simulation_call.EconomySimulationDependencies( + worker=stage12_runtime.SimulationWorkerDependencies( + segmented_calculator=( + segmented_calculator if country == "us" else None + ), + runtime=runtime, + ) + ), + ) + finally: + runtime.shutdown() + + +@app.function( + image=us_worker_image, + cpu=8.0, + memory=32768, + timeout=US_SIMULATION_TIMEOUT_SECONDS, + retries=0, + max_containers=10, + secrets=worker_secrets, +) +def run_economy_simulation_us( + call: dict, *, observability_context: dict | None = None +) -> dict: + """Calculate one US v2 economy simulation run and report that it ended.""" + return _run_economy_simulation("us", call, observability_context) + + +@app.function( + image=uk_worker_image, + cpu=8.0, + memory=32768, + timeout=UK_SIMULATION_TIMEOUT_SECONDS, + retries=0, + max_containers=10, + secrets=worker_secrets, +) +def run_economy_simulation_uk( + call: dict, *, observability_context: dict | None = None +) -> dict: + """Calculate one UK v2 economy simulation run and report that it ended.""" + return _run_economy_simulation("uk", call, observability_context) + + +@app.function( + image=coordinator_image, + cpu=2.0, + memory=8192, + timeout=ECONOMY_COMPARISON_TIMEOUT_SECONDS, + retries=0, + max_containers=10, + secrets=worker_secrets, +) +def compare_economy_simulations( + call: dict, *, observability_context: dict | None = None +) -> dict: + """Compare a v2 economy report run's two simulation outputs; report the end.""" + from src.modal.call_reports import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + reporting_call_end, + ) + + runtime = init_process_observability( + service_name="policyengine-economy-comparison", + service_role="economy_comparison", + platform="modal", + environment=os.getenv("MODAL_ENVIRONMENT", "local"), + ) + report_run_id = _payload_run_id(call, "report_run_id") + try: + with ( + reporting_call_end( + modal_call_id=modal.current_function_call_id(), + report_run_id=report_run_id, + start_report=start_call_report, + ), + runtime.operation( + STAGE12_CANONICAL_REPORT_STAGES.name(Stage.STAGE12_AGGREGATION), + attributes={ + "runner_name": "stage12", + "result_delivery": ReportResultDelivery.RETURN_TO_CALLER.value, + "report_run_id": str(report_run_id), + }, + remote_context=normalize_observability_context(observability_context), + ), + ): + return comparison_call.compare_economy_simulations( + call, + dependencies=comparison_call.EconomyComparisonDependencies( + runtime=runtime + ), + ) + 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``) 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, + ) + + return send_call_ended_report_from_container(report) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/__init__.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/__init__.py new file mode 100644 index 000000000..40a59bd22 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/__init__.py @@ -0,0 +1,7 @@ +"""The v2 economy path's Modal calls: one per simulation run, one per comparison. + +The Simulation Entrypoint starts these calls for its simulation runs and +report runs (``policyengine_simulation_contract.economy_runs``). They run on +the Stage 12 workers' machinery, with the region and dataset each run's +catalog version has. +""" diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/comparison_call.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/comparison_call.py new file mode 100644 index 000000000..edd17da59 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/comparison_call.py @@ -0,0 +1,299 @@ +"""Compare two stored v2 economy simulation outputs: one report run's comparison. + +The call (``EconomyComparisonCall``) names the report run, and each side's +simulation run, policy and stored output. The worker reads both outputs, +checks each against the digest sim recorded for it, requires both to hold +the same households and the columns this pair's comparison reads (the +labour supply response columns when either policy changes an elasticity), +and aggregates them with the Stage 12 aggregation. It returns the +``SingleYearMacroOutput``; sim validates it and stores it as rows. It writes +nothing, so a call Modal restarts after pre-empting it simply runs again. + +A failure reaches the caller as ``ReportExecutionError``, with the redacted +code and summary a returned Stage 12 report carries. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from hashlib import sha256 +from typing import TYPE_CHECKING, Any +from uuid import uuid5 + +from policyengine_simulation_contract.economy_runs import ( + EconomyComparisonCall, + policy_affects_labor_supply, +) +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.stage12_execution import ( + AggregateReportArtifactPayload, + ArtifactMediaType, + ArtifactReference, + GeographySelection, + ReportAggregate, + ReportExecutionInput, + RowIdentity, + SimulationArtifactDescriptor, + SimulationExecutionInput, + SimulationRole, + stage12_output_plan_sha256, +) + +from policyengine_simulation_executor.economy_runs.provenance import ( + catalog_bundle_provenance, +) +from policyengine_simulation_executor.stage12_artifacts import ( + canonical_json_bytes, + deserialize_simulation_frames, + deserialize_uk_local_authority_metadata, +) +from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle +from policyengine_simulation_executor.stage12_runtime.aggregation import ( + RetainedSimulationOutput, + build_aggregate_report, + validate_uk_local_authority_metadata, +) +from policyengine_simulation_executor.stage12_runtime.dependencies import ( + artifact_store, +) +from policyengine_simulation_executor.stage12_runtime.failure_recording import ( + FailureSite, + report_failure, +) +from policyengine_simulation_executor.stage12_runtime.failures import ( + report_execution_error, +) +from policyengine_simulation_executor.stage12_runtime.output_planning import ( + resolve_simulation_output_plan, + validate_output_frames, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + import pandas as pd + from policyengine_observability import ObservabilityRuntime + from policyengine_simulation_contract.economy_runs import EconomyComparisonSide + from policyengine_simulation_contract.stage12_execution import ( + BundleProvenance, + DatasetPopulationInput, + Stage12OutputPlan, + ) + from policyengine_simulation_contract.uk_geography import ( + UKLocalAuthorityMetadata, + ) + + from policyengine_simulation_executor.stage12_artifacts import ( + Stage12ArtifactStore, + ) + + +@dataclass(frozen=True) +class EconomyComparisonDependencies: + """The comparison's seams; unset seams use the deployed defaults. + + ``artifacts`` reads the stored outputs, ``aggregator`` builds the + aggregate report, ``output_planner`` plans the columns each side must + hold, and ``runtime`` receives the failure report. + """ + + artifacts: Stage12ArtifactStore | None = None + aggregator: Callable[..., dict[str, Any]] = build_aggregate_report + output_planner: Callable[..., Stage12OutputPlan] = resolve_simulation_output_plan + runtime: ObservabilityRuntime | None = None + + +@dataclass(frozen=True) +class _StoredOutput: + frames: Mapping[str, pd.DataFrame] + size_bytes: int + uk_local_authority_metadata: UKLocalAuthorityMetadata | None + + +def _row_identity(frames: Mapping[str, pd.DataFrame]) -> RowIdentity: + """Fingerprint the rows of a stored output by each entity's identifiers.""" + identifiers = { + entity: sorted(str(value) for value in frames[entity][f"{entity}_id"].tolist()) + for entity in sorted(frames) + } + return RowIdentity( + identifier_columns=tuple(f"{entity}.{entity}_id" for entity in identifiers), + row_count=sum(len(values) for values in identifiers.values()), + identity_sha256=sha256(canonical_json_bytes(identifiers)).hexdigest(), + ) + + +def _read_output( + storage: Stage12ArtifactStore, side: EconomyComparisonSide +) -> _StoredOutput: + """Read one side's stored output; raise ValueError if its bytes changed.""" + payload = storage.read(side.output_uri) + if sha256(payload).hexdigest() != side.output_sha256: + msg = "a stored simulation output differs from the digest recorded for it" + raise ValueError(msg) + return _StoredOutput( + frames=deserialize_simulation_frames(payload), + size_bytes=len(payload), + uk_local_authority_metadata=deserialize_uk_local_authority_metadata(payload), + ) + + +def _simulation( + call: EconomyComparisonCall, + side: EconomyComparisonSide, + role: SimulationRole, + *, + provenance: BundleProvenance, + population: DatasetPopulationInput, +) -> SimulationExecutionInput: + # A run can be compared with itself, and the report's two simulations need + # identifiers of their own: each is named after the report run and its role. + return SimulationExecutionInput( + evaluation_id=call.report_run_id, + simulation_execution_id=uuid5(call.report_run_id, role.value), + role=role, + policy=dict(side.policy), + population=population, + year=call.year, + geography=GeographySelection(country=call.country, region=call.region_code), + bundle=provenance, + ) + + +class _Comparison: + """One comparison call, run step by step.""" + + def __init__( + self, call: EconomyComparisonCall, dependencies: EconomyComparisonDependencies + ) -> None: + self.call = call + self.dependencies = dependencies + provenance, population = catalog_bundle_provenance( + country=call.country, + policyengine_version=call.policyengine_version, + dataset_name=call.dataset_name, + resolved=load_stage12_bundle(), + ) + self.report = ReportExecutionInput( + evaluation_id=call.report_run_id, + baseline=_simulation( + call, + call.baseline, + SimulationRole.BASELINE, + provenance=provenance, + population=population, + ), + reform=_simulation( + call, + call.reform, + SimulationRole.REFORM, + provenance=provenance, + population=population, + ), + requested_aggregates=tuple(ReportAggregate), + ) + + def _retained( + self, + simulation: SimulationExecutionInput, + side: EconomyComparisonSide, + stored: _StoredOutput, + plan: Stage12OutputPlan, + ) -> RetainedSimulationOutput: + validate_output_frames(stored.frames, plan) + return RetainedSimulationOutput( + frames=stored.frames, + descriptor=SimulationArtifactDescriptor( + evaluation_id=self.call.report_run_id, + simulation_execution_id=simulation.simulation_execution_id, + role=simulation.role, + artifact=ArtifactReference( + uri=side.output_uri, + media_type=ArtifactMediaType.PARQUET, + content_sha256=side.output_sha256, + size_bytes=stored.size_bytes, + ), + output_plan_sha256=stage12_output_plan_sha256(plan), + row_identity=_row_identity(stored.frames), + bundle=simulation.bundle, + ), + ) + + def run(self) -> dict[str, Any]: + call = self.call + storage = self.dependencies.artifacts or artifact_store() + baseline_output = _read_output(storage, call.baseline) + reform_output = _read_output(storage, call.reform) + # Both sides' outputs must hold the columns this pair's comparison + # reads: the labour supply response columns when either policy + # changes an elasticity. + labor_supply_response = any( + policy_affects_labor_supply(call.country, side.policy) + for side in (call.baseline, call.reform) + ) + plan = self.dependencies.output_planner( + self.report.baseline, labor_supply_response=labor_supply_response + ) + baseline = self._retained( + self.report.baseline, call.baseline, baseline_output, plan + ) + reform = self._retained(self.report.reform, call.reform, reform_output, plan) + if baseline.descriptor.row_identity != reform.descriptor.row_identity: + msg = "the simulation outputs hold different households" + raise ValueError(msg) + aggregate = AggregateReportArtifactPayload.model_validate( + self.dependencies.aggregator( + report=self.report, + baseline=baseline, + reform=reform, + uk_local_authority_metadata=validate_uk_local_authority_metadata( + call.country, + baseline_output.uk_local_authority_metadata, + reform_output.uk_local_authority_metadata, + region_code=call.region_code, + ), + ) + ) + if ( + aggregate.evaluation_id != self.report.evaluation_id + or aggregate.bundle != self.report.baseline.bundle + or aggregate.requested_aggregates != self.report.requested_aggregates + ): + msg = "the aggregate does not describe this comparison" + raise ValueError(msg) + return json.loads( + SingleYearMacroOutput.model_validate(aggregate.result).model_dump_json() + ) + + +def compare_economy_simulations( + payload: object, *, dependencies: EconomyComparisonDependencies | None = None +) -> dict[str, Any]: + """Compare a report run's two stored simulation outputs; return the result. + + Return a ``SingleYearMacroOutput`` in JSON mode. A call for another + policyengine.py version, or a dataset the installed release lacks, + raises ValueError before anything is read. Any later failure is reported + once and raised as ``ReportExecutionError`` for the report run. + """ + dependencies = dependencies or EconomyComparisonDependencies() + call = EconomyComparisonCall.model_validate(payload) + comparison = _Comparison(call, dependencies) + try: + return comparison.run() + except Exception as error: # noqa: BLE001 # every failure reaches the caller as a typed error with its redacted detail + detail = report_failure( + error, + FailureSite( + scope="economy_comparison", + default_code="economy_comparison_failed", + attributes={ + "report_run_id": str(call.report_run_id), + "baseline_simulation_run_id": str(call.baseline.simulation_run_id), + "reform_simulation_run_id": str(call.reform.simulation_run_id), + }, + ), + runtime=dependencies.runtime, + ) + raise report_execution_error(call.report_run_id, detail) from None diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/provenance.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/provenance.py new file mode 100644 index 000000000..9bb24a575 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/provenance.py @@ -0,0 +1,69 @@ +"""The bundle provenance of the dataset a v2 economy run's catalog names.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_simulation_contract.stage12_execution import ( + BundleProvenance, + DatasetArtifactMediaType, + DatasetArtifactReference, + DatasetPopulationInput, + DatasetProvenance, +) + +from policyengine_simulation_executor.stage12_runtime.catalog_population import ( + catalog_dataset, +) + +if TYPE_CHECKING: + from policyengine_simulation_contract.stage12_bundle import ( + CountryId, + ResolvedStage12Bundle, + ) + + +def catalog_bundle_provenance( + *, + country: CountryId, + policyengine_version: str, + dataset_name: str, + resolved: ResolvedStage12Bundle, +) -> tuple[BundleProvenance, DatasetPopulationInput]: + """Return the provenance and population of the dataset the catalog names. + + Raise ValueError when this application installs another policyengine.py + version than ``policyengine_version``, or a release without the dataset. + """ + installed = resolved.bundle.policyengine_version + if installed != policyengine_version: + msg = ( + f"the call names PolicyEngine.py {policyengine_version}, but this " + f"application installs PolicyEngine.py {installed}" + ) + raise ValueError(msg) + country_bundle = next( + item for item in resolved.bundle.countries if item.country == country + ) + dataset = catalog_dataset(country_bundle, dataset_name) + provenance = BundleProvenance( + policyengine_version=installed, + country_package_name=country_bundle.country_package_name, + country_package_version=country_bundle.country_package_version, + dataset=DatasetProvenance( + identity=dataset.identity, + uri=dataset.uri, + artifact_revision=dataset.artifact_revision, + data_package_name=country_bundle.data_package_name, + data_package_version=country_bundle.data_package_version, + ), + bundle_manifest_sha256=resolved.bundle_manifest_sha256, + ) + population_input = DatasetPopulationInput( + artifact=DatasetArtifactReference( + uri=dataset.uri, + media_type=DatasetArtifactMediaType.HDF5, + content_sha256=dataset.sha256, + ) + ) + return provenance, population_input diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/simulation_call.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/simulation_call.py new file mode 100644 index 000000000..db22284bc --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/economy_runs/simulation_call.py @@ -0,0 +1,208 @@ +"""Calculate one v2 economy simulation run and store its output. + +The call (``EconomySimulationCall``) names the run, its policy's parameter +values, its year and the region and dataset its catalog version has. The +worker plans the run's output columns for any report it may be compared in +(``resolve_simulation_output_plan``), and calculates it as a Stage 12 worker +calculates a simulation whose result returns to its caller: it reads and +writes no database rows, scopes the catalog's dataset with the catalog's +filter (``catalog_population``), calculates a US national run in segments, +and stores its input and output under keys naming their SHA-256, so a call +Modal restarts after pre-empting it can always store its own. A simulation +run has no report role; the Stage 12 input names it the baseline. + +The artifacts go under the Stage 12 layout for the run +(``stage-12-runs////``), in the +bucket whose files are deleted after 30 days. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime +from hashlib import sha256 +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.economy_runs import ( + EconomySimulationCall, + EconomySimulationOutput, +) +from policyengine_simulation_contract.stage12_execution import ( + MAX_COMPARISON_RUN_ARTIFACT_RETENTION, + ArtifactMediaType, + ArtifactReference, + GeographySelection, + PlannedSimulationExecutionInput, + ReportResultDelivery, + SimulationArtifactDescriptor, + SimulationExecutionInput, + SimulationRole, + Stage12InvocationContext, + Stage12OutputPlan, +) + +from policyengine_simulation_executor.economy_runs.provenance import ( + catalog_bundle_provenance, +) +from policyengine_simulation_executor.stage12_artifacts import ( + canonical_json_bytes, + comparison_run_prefix, + content_addressed_input_path, +) +from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle +from policyengine_simulation_executor.stage12_runtime import ( + SimulationWorkerDependencies, + run_single_simulation, +) +from policyengine_simulation_executor.stage12_runtime.catalog_population import ( + CATALOG_POPULATION_OPTION, +) +from policyengine_simulation_executor.stage12_runtime.dependencies import ( + artifact_store, +) +from policyengine_simulation_executor.stage12_runtime.output_planning import ( + resolve_simulation_output_plan, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_simulation_contract.stage12_bundle import CountryId + + from policyengine_simulation_executor.stage12_artifacts import ( + Stage12ArtifactStore, + ) + + +@dataclass(frozen=True) +class EconomyRunInvocation: + """The deployed Modal function running the call, as its context names it.""" + + application_name: str + modal_environment: str + simulation_callable: str + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _input_reference( + storage: Stage12ArtifactStore, + prefix: str, + simulation: PlannedSimulationExecutionInput, +) -> ArtifactReference: + """Name the input file the worker stored under its digest.""" + payload = canonical_json_bytes(simulation.model_dump(mode="json")) + digest = sha256(payload).hexdigest() + path = content_addressed_input_path( + prefix=prefix, role=simulation.role.value, content_sha256=digest + ) + return ArtifactReference( + uri=f"gs://{storage.bucket_name}/{path}", + media_type=ArtifactMediaType.JSON, + content_sha256=digest, + size_bytes=len(payload), + ) + + +@dataclass(frozen=True) +class EconomySimulationDependencies: + """The call's seams; unset seams use the deployed defaults. + + ``worker`` holds the Stage 12 worker's seams, ``output_planner`` plans + the run's output columns, and ``clock`` dates its artifact paths. + """ + + worker: SimulationWorkerDependencies = field( + default_factory=SimulationWorkerDependencies + ) + output_planner: Callable[..., Stage12OutputPlan] = resolve_simulation_output_plan + clock: Callable[[], datetime] = _utc_now + + +def run_economy_simulation( + payload: object, + *, + required_country: CountryId, + invocation: EconomyRunInvocation, + dependencies: EconomySimulationDependencies | None = None, +) -> dict[str, Any]: + """Calculate one simulation run, store its input and output, and describe them. + + Return an ``EconomySimulationOutput`` in JSON mode. A call for another + country, another policyengine.py version or a dataset the installed + release lacks raises ValueError before anything is stored. A failed + calculation raises ``ReportExecutionError`` with its redacted detail, as + a returned Stage 12 simulation does. + """ + dependencies = dependencies or EconomySimulationDependencies() + call = EconomySimulationCall.model_validate(payload) + if call.country != required_country: + msg = "the call was sent to another country's simulation function" + raise ValueError(msg) + provenance, population = catalog_bundle_provenance( + country=call.country, + policyengine_version=call.policyengine_version, + dataset_name=call.population.dataset_name, + resolved=load_stage12_bundle(), + ) + simulation = SimulationExecutionInput( + evaluation_id=call.simulation_run_id, + simulation_execution_id=call.simulation_run_id, + role=SimulationRole.BASELINE, + policy=dict(call.policy), + population=population, + year=call.year, + geography=GeographySelection( + country=call.country, region=call.population.region_code + ), + options={CATALOG_POPULATION_OPTION: call.population.model_dump(mode="json")}, + bundle=provenance, + ) + planned = PlannedSimulationExecutionInput( + **simulation.model_dump(), + output_plan=dependencies.output_planner( + simulation, labor_supply_response=call.labor_supply_response + ), + ) + created_at = dependencies.clock() + prefix = comparison_run_prefix( + environment=call.artifact_environment, + created_at=created_at, + evaluation_id=call.simulation_run_id, + ) + context = Stage12InvocationContext( + request_id=str(call.simulation_run_id), + environment=call.artifact_environment, + modal_environment=invocation.modal_environment, + worker_version=provenance.policyengine_version, + modal_application=invocation.application_name, + simulation_callable=invocation.simulation_callable, + # The v2 economy path deploys no version manifest of its own; a + # returned simulation reads neither digest. + version_manifest_sha256=provenance.bundle_manifest_sha256, + bundle_manifest_sha256=provenance.bundle_manifest_sha256, + artifact_prefix=prefix, + created_at=created_at, + retention_expires_at=created_at + MAX_COMPARISON_RUN_ARTIFACT_RETENTION, + result_delivery=ReportResultDelivery.RETURN_TO_CALLER, + ) + worker = dependencies.worker + storage = worker.artifacts or artifact_store() + descriptor = SimulationArtifactDescriptor.model_validate( + run_single_simulation( + planned.model_dump(mode="json"), + context.model_dump(mode="json"), + required_country=required_country, + dependencies=replace(worker, artifacts=storage), + ) + ) + return EconomySimulationOutput( + simulation_run_id=call.simulation_run_id, + input=_input_reference(storage, prefix, planned), + output=descriptor.artifact, + output_plan_sha256=descriptor.output_plan_sha256, + row_identity=descriptor.row_identity, + bundle=descriptor.bundle, + ).model_dump(mode="json") diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_artifacts.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_artifacts.py index 71877ee60..73804c6d0 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_artifacts.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_artifacts.py @@ -77,11 +77,41 @@ def input_path(*, prefix: str, role: str) -> str: return f"{prefix}/inputs/{role}.json" +def content_addressed_input_path( + *, + prefix: str, + role: str, + content_sha256: str, +) -> str: + """Return the key of a planned execution input JSON named by its own SHA-256. + + A restart that plans a simulation differently, for example on a + redeployed application, stores its input under a new key; an identical + input reuses the earlier object. + """ + return f"{prefix}/inputs/{role}-{content_sha256}.json" + + def simulation_path(*, prefix: str, role: str) -> str: """Return the key of the Parquet output for one simulation role.""" return f"{prefix}/simulations/{role}.parquet" +def content_addressed_simulation_path( + *, + prefix: str, + role: str, + content_sha256: str, +) -> str: + """Return the key of a Parquet output named by its own SHA-256. + + A restarted simulation whose output differs in any byte stores it under a + new key instead of colliding with the earlier attempt's object, and an + identical output reuses the earlier object. + """ + return f"{prefix}/simulations/{role}-{content_sha256}.parquet" + + def aggregate_path(*, prefix: str) -> str: """Return the key of the run's aggregate report JSON.""" return f"{prefix}/reports/aggregate.json" @@ -322,6 +352,28 @@ def write_input( content_type=REPORT_MEDIA_TYPE, ) + def write_input_named_by_digest( + self, + *, + prefix: str, + simulation: PlannedSimulationExecutionInput, + ) -> ArtifactReference: + """Store a simulation's planned execution input under a key naming its SHA-256. + + A restarted simulation can always store its input this way (see + ``content_addressed_input_path``). + """ + payload = canonical_json_bytes(simulation.model_dump(mode="json")) + return self._write_immutable( + content_addressed_input_path( + prefix=prefix, + role=simulation.role.value, + content_sha256=sha256(payload).hexdigest(), + ), + payload, + content_type=REPORT_MEDIA_TYPE, + ) + def write_simulation( self, *, @@ -331,14 +383,66 @@ def write_simulation( calculation_provenance: SPMProvenance | None = None, uk_local_authority_metadata: UKLocalAuthorityMetadata | None = None, ) -> SimulationArtifactDescriptor: - """Store a simulation's output frames and describe the artifact for reports.""" + """Store a simulation's output frames under its role's fixed key. + + Returns the artifact's descriptor for reports. + """ payload, row_identity = serialize_simulation_frames( frames, calculation_provenance=calculation_provenance, uk_local_authority_metadata=uk_local_authority_metadata, ) - artifact = self._write_immutable( + return self._store_simulation_payload( simulation_path(prefix=prefix, role=simulation.role.value), + simulation, + payload, + row_identity, + calculation_provenance, + ) + + def write_simulation_named_by_digest( + self, + *, + prefix: str, + simulation: PlannedSimulationExecutionInput, + frames: Mapping[str, pd.DataFrame], + calculation_provenance: SPMProvenance | None = None, + uk_local_authority_metadata: UKLocalAuthorityMetadata | None = None, + ) -> SimulationArtifactDescriptor: + """Store a simulation's output frames under a key naming their SHA-256. + + A restarted simulation can always store its output this way (see + ``content_addressed_simulation_path``). Returns the artifact's + descriptor for reports. + """ + payload, row_identity = serialize_simulation_frames( + frames, + calculation_provenance=calculation_provenance, + uk_local_authority_metadata=uk_local_authority_metadata, + ) + return self._store_simulation_payload( + content_addressed_simulation_path( + prefix=prefix, + role=simulation.role.value, + content_sha256=sha256(payload).hexdigest(), + ), + simulation, + payload, + row_identity, + calculation_provenance, + ) + + def _store_simulation_payload( + self, + path: str, + simulation: PlannedSimulationExecutionInput, + payload: bytes, + row_identity: RowIdentity, + calculation_provenance: SPMProvenance | None, + ) -> SimulationArtifactDescriptor: + """Store one encoded simulation output at ``path`` and describe it.""" + artifact = self._write_immutable( + path, payload, content_type=SIMULATION_MEDIA_TYPE, ) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/__init__.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/__init__.py index 0bc91c889..cf49c79ce 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/__init__.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/__init__.py @@ -28,6 +28,7 @@ stage12_region_groups, stage12_region_groups_for_model, ) +from .returned_coordination import coordinate_report_for_caller from .segment_contracts import Stage12SegmentInput, Stage12SegmentResult from .segmentation import ( build_segment_inputs, @@ -67,6 +68,7 @@ "calculate_segment", "calculate_simulation_frames", "coordinate_report", + "coordinate_report_for_caller", "merge_segment_results", "redacted_failure_detail", "run_segmented_simulation", diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/catalog_population.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/catalog_population.py new file mode 100644 index 000000000..6ba18b9bc --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/catalog_population.py @@ -0,0 +1,166 @@ +"""Resolve a v2 economy run's region and dataset from its catalog population. + +A v2 economy simulation run carries the region and dataset its catalog +version has (``CatalogPopulation``) in its options, under +``CATALOG_POPULATION_OPTION``. Its calculation then scopes the dataset with +the catalog's household filter and loads the dataset the catalog names, +instead of looking the region up in policyengine.py's region registry. A US +national run calculated in segments scopes each segment to its states' +catalog filters. Stage 12 comparison runs carry no catalog population and +resolve their regions as before. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.economy_runs import CatalogPopulation + +from .partition import stage12_region_groups + +if TYPE_CHECKING: + from policyengine.core.scoping_strategy import RowFilterStrategy + from policyengine_simulation_contract.economy_runs import CatalogRowFilter + from policyengine_simulation_contract.stage12_bundle import ( + Stage12CountryBundle, + Stage12Dataset, + ) + from policyengine_simulation_contract.stage12_execution import ( + SimulationExecutionInput, + ) + + +CATALOG_POPULATION_OPTION = "catalog_population" +REGION_GROUP_OPTION = "region_group" + + +@dataclass(frozen=True) +class CatalogRegion: + """The region a calculation simulates: its code and how it is scoped. + + ``scoping_strategy`` is None for a national region; ``state_code`` names + the US state of a state or district region. The Stage 12 calculation + reads the same three fields of its own region type. + """ + + code: str + scoping_strategy: Any | None = None + state_code: str | None = None + + +@dataclass(frozen=True) +class CatalogDatasetChoice: + """The release dataset a calculation loads, and whether it is the default.""" + + name: str + uri: str + is_default: bool + + +def catalog_population( + simulation: SimulationExecutionInput, +) -> CatalogPopulation | None: + """Return the simulation's catalog population, or None for a Stage 12 run.""" + payload = simulation.options.get(CATALOG_POPULATION_OPTION) + if payload is None: + return None + return CatalogPopulation.model_validate(payload) + + +def _row_filter(row_filter: CatalogRowFilter) -> RowFilterStrategy: + from policyengine.core.scoping_strategy import ( # noqa: PLC0415 # policyengine loads in the worker, not where the contract is imported + RowFilterStrategy, + ) + + value = row_filter.household_value() + if isinstance(value, list): + values: list[str | int | float] = list(value) + return RowFilterStrategy(variable_name=row_filter.field, variable_value=values) + return RowFilterStrategy(variable_name=row_filter.field, variable_value=value) + + +def resolve_catalog_region( + simulation: SimulationExecutionInput, population: CatalogPopulation +) -> CatalogRegion: + """Return the region the catalog population describes, as the worker scopes it. + + A segment of a national run (``region_group`` in its options) is scoped to + the union of its states' catalog filters; raise ValueError when the + population has no filter for one of them. + """ + from policyengine.core.scoping_strategy import ( # noqa: PLC0415 # policyengine loads in the worker, not where the contract is imported + RegionGroupStrategy, + ) + + group = simulation.options.get(REGION_GROUP_OPTION) + if isinstance(group, list) and group: + codes = [str(code) for code in group] + filters = population.state_filters or {} + missing = [code for code in codes if code not in filters] + if missing: + msg = f"the catalog population has no filter for {', '.join(missing)}" + raise ValueError(msg) + return CatalogRegion( + code="region_group/" + "+".join(sorted(codes)), + scoping_strategy=RegionGroupStrategy( + members=[_row_filter(filters[code]) for code in codes] + ), + ) + return CatalogRegion( + code=population.region_code, + scoping_strategy=( + None + if population.row_filter is None + else _row_filter(population.row_filter) + ), + state_code=population.state_code, + ) + + +def catalog_dataset(country_bundle: Stage12CountryBundle, name: str) -> Stage12Dataset: + """Return the installed release's dataset the catalog names ``name``. + + Raise ValueError when the release has no dataset of that name. + """ + dataset = next( + (item for item in country_bundle.datasets if item.identity == name), None + ) + if dataset is None: + msg = f"the installed release has no dataset {name!r}, which the catalog names" + raise ValueError(msg) + return dataset + + +def resolve_catalog_dataset( + country_bundle: Stage12CountryBundle, population: CatalogPopulation +) -> CatalogDatasetChoice: + """Return the dataset selection the calculation loads for the population.""" + dataset = catalog_dataset(country_bundle, population.dataset_name) + return CatalogDatasetChoice( + name=dataset.identity, + uri=dataset.uri, + is_default=dataset.identity == country_bundle.default_dataset, + ) + + +def catalog_region_groups(population: CatalogPopulation) -> list[list[str]]: + """Return the US partition's region groups for the population's states. + + Every state the catalog filters is calculated: a state the partition + does not list joins its last group. Raise ValueError when the partition + lists a state the catalog has no filter for, since its households would + be left out of the national total. + """ + groups = stage12_region_groups("us") + filters = population.state_filters + if groups is None or not filters: + msg = "a segmented run needs the catalog's state filters" + raise ValueError(msg) + partitioned = {code for group in groups for code in group} + missing = sorted(partitioned - set(filters)) + if missing: + msg = f"the catalog population has no filter for {', '.join(missing)}" + raise ValueError(msg) + groups[-1].extend(sorted(set(filters) - partitioned)) + return groups diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordinated_simulations.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordinated_simulations.py index 86016e19b..c54ed68d8 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordinated_simulations.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordinated_simulations.py @@ -134,21 +134,34 @@ def cancel_pending_simulations( cancellation_recorded = True if cancellation_recorded and should_segment_simulation(simulation): # A segmented worker reads its record, cancels the region-group - # calls it started, and exits. Modal does not cancel spawned calls - # recursively, so cancelling the worker through Modal would leave - # those calls running. That is the fallback only when the record - # could not be written and the worker cannot learn of the - # cancellation. + # calls it started, and exits through its own failure handling. + # Cancelling it through Modal instead raises Modal's + # InputCancellation in it, which also makes it cancel those calls + # (Stage12SegmentRunner.run), but records nothing: that is the + # fallback only when the record could not be written and the + # worker cannot learn of the cancellation. continue - try: - calls[role].cancel() - except Exception as error: # noqa: BLE001 # a failed Modal cancellation is logged and must not replace the failure that caused it - _log_child_operation_failure( - logging.WARNING, - "Stage 12 could not cancel %s simulation %s in report %s (%s)", - simulation=simulation, - error=error, - ) + cancel_child_call(calls[role], simulation) + + +def cancel_child_call( + call: ChildCall, + simulation: PlannedSimulationExecutionInput, +) -> None: + """Ask Modal to stop one started child; log a failure rather than raise it. + + A cancellation error must never replace the failure that caused the + cancellation. + """ + try: + call.cancel() + except Exception as error: # noqa: BLE001 # a failed Modal cancellation is logged and must not replace the failure that caused it + _log_child_operation_failure( + logging.WARNING, + "Stage 12 could not cancel %s simulation %s in report %s (%s)", + simulation=simulation, + error=error, + ) def simulation_failure_attributes( @@ -197,6 +210,26 @@ def record_child_failure( _log_child_state_failure(simulation=simulation, error=state_error) +def report_child_wait_failure( + error: Exception, + *, + simulation: PlannedSimulationExecutionInput, + runtime: ObservabilityRuntime | None, + progress: StageProgress, +) -> Stage12FailureDetail: + """Report a failed child call once and return its redacted detail.""" + return report_failure( + error, + FailureSite( + scope="child_wait", + default_code="simulation_invocation_failed", + attributes=simulation_failure_attributes(simulation), + ), + runtime=runtime, + progress=progress, + ) + + def child_failure_detail( *, error: Exception, @@ -213,13 +246,9 @@ def child_failure_detail( the call's own failure is used and still recorded where possible, so a state error never replaces the child's failure. """ - detail = report_failure( + detail = report_child_wait_failure( error, - FailureSite( - scope="child_wait", - default_code="simulation_invocation_failed", - attributes=simulation_failure_attributes(simulation), - ), + simulation=simulation, runtime=runtime, progress=progress, ) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordination.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordination.py index d64f9fd2f..1f4eaf6be 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordination.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/coordination.py @@ -1,8 +1,11 @@ -"""Coordination of a Stage 12 report's baseline and reform simulations. +"""Coordination of a report's baseline and reform simulations. -coordinate_report runs one report; report_coordination, -coordinated_simulations and parent_claim hold its steps, and this module -re-exports their public names. +``ReportSimulationCoordination`` holds the steps every report coordinator +runs. ``coordinate_report`` runs a Stage 12 report and records each step in +the Stage 12 tables; ``returned_coordination`` runs the same steps for a +report whose result returns to its caller and records nothing. +report_coordination, coordinated_simulations and parent_claim hold the +steps, and this module re-exports their public names. """ from __future__ import annotations @@ -12,6 +15,7 @@ from policyengine_simulation_contract.stage12_execution import ( ComparisonReportRecord, ReportExecutionInput, + ReportResultDelivery, Stage12InvocationContext, ) from policyengine_simulation_observability.stages import ( @@ -39,6 +43,7 @@ CoordinatorDependencies, CoordinatorInvocation, ReportCoordination, + ReportSimulationCoordination, ) __all__ = [ @@ -49,8 +54,10 @@ "SIMULATION_WAIT_TIMEOUT_SECONDS", "CoordinatorDependencies", "CoordinatorInvocation", + "ReportSimulationCoordination", "coordinate_report", "logger", + "validate_dispatch_context", ] @@ -68,16 +75,22 @@ def coordinate_report( ``deduplicated`` summary instead), starts or reattaches the baseline and reform child simulations within one shared wait window, and aggregates their digest-checked artifacts; shadow runs are then compared with the - production result. A dispatch context that disagrees with the report - raises ``ValueError`` before the claim; any later failure stops the - unfinished children, marks the report FAILED and raises a - ``RuntimeError`` that carries no calculation detail. + production result. A dispatch context that disagrees with the report, or + asks for the report to return to its caller, raises ``ValueError`` before + the claim; any later failure stops the unfinished children, marks the + report FAILED and raises a ``RuntimeError`` that carries no calculation + detail. """ dependencies = dependencies or CoordinatorDependencies() # Open the shared child-wait window before any setup work. child_wait_deadline = dependencies.monotonic() + dependencies.wait_timeout_seconds report = ReportExecutionInput.model_validate(payload) context = Stage12InvocationContext.model_validate(context_payload) + if context.result_delivery is not ReportResultDelivery.STAGE12_DATABASE: + msg = ( + "a report returned to its caller runs through coordinate_report_for_caller" + ) + raise ValueError(msg) submitted_parent = ComparisonReportRecord.model_validate(parent_payload) validate_dispatch_context(report, context, invocation.application_name) validate_submitted_parent( diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/failures.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/failures.py index fd6b36573..ed5b0b537 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/failures.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/failures.py @@ -4,14 +4,21 @@ from collections.abc import Mapping from datetime import date -from typing import Any, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn +from policyengine_simulation_contract.report_execution import ( + ReportExecutionError, + ReportExecutionFailure, +) from policyengine_simulation_contract.spm import spm_error_detail from policyengine_simulation_observability.errors import make_correlation_id from pydantic import BaseModel, ConfigDict from .failure_causes import StageProgress, failure_summary +if TYPE_CHECKING: + from uuid import UUID + ERROR_CODE_MAX_LENGTH = 64 ERROR_SUMMARY_MAX_LENGTH = 512 @@ -83,6 +90,24 @@ def failure_detail_from_record( ) +def report_execution_error( + evaluation_id: UUID, + detail: Stage12FailureDetail, +) -> ReportExecutionError: + """Return the typed error that carries ``detail`` to a returned report's caller. + + It holds the error code and summary a Stage 12 row would record for the + same failure. + """ + return ReportExecutionError( + ReportExecutionFailure( + evaluation_id=evaluation_id, + error_code=detail.error_code, + error_summary=detail.error_summary or "Simulation failed", + ) + ) + + def redacted_failure_detail( error: BaseException, *, diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/fanout.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/fanout.py index 83194b9f5..b9557395d 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/fanout.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/fanout.py @@ -15,6 +15,7 @@ from policyengine_simulation_executor.simulation_runtime import _country_module +from .catalog_population import catalog_population, catalog_region_groups from .failure_recording import FailureSite, report_failure from .failures import Stage12CancellationError, Stage12ExecutionError from .partition import stage12_region_groups_for_model @@ -29,6 +30,7 @@ PlannedSimulationExecutionInput, ) + from .segment_contracts import Stage12SegmentInput from .simulation import SimulationCalculation logger = logging.getLogger(__name__) @@ -107,6 +109,10 @@ def __init__( def _groups(self) -> list[list[str]]: if self.groups is not None: return self.groups + # A v2 economy run segments the states its catalog population filters. + population = catalog_population(self.simulation) + if population is not None: + return catalog_region_groups(population) country = self.simulation.geography.country return stage12_region_groups_for_model( country, @@ -119,7 +125,9 @@ def run(self) -> SimulationCalculation: A segment whose result cannot be read twice in a row raises ``Stage12ExecutionError``, and a requested cancellation raises ``Stage12CancellationError``. Either way, every started segment is - cancelled first. + cancelled first. Every started segment is also cancelled when the + run stops for any other reason before the results are collected, + such as a failed dispatch or Modal cancelling this worker's input. """ groups = self._groups() segments = build_segment_inputs(self.simulation, groups=groups) @@ -128,27 +136,24 @@ def run(self) -> SimulationCalculation: stage12_segment_count=len(segments), ) handles: list[tuple[int, Any]] = [] + payloads: list[object] | None = None try: - for segment in segments: - call = None - with self.runtime.span( - STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SEGMENT_DISPATCH), - attributes={"segment_index": segment.segment_index}, - ): - call = self.child_function.spawn( - segment.model_dump(mode="python"), - observability_context=self.runtime.capture_context(), - ) - handles.append((segment.segment_index, _require_call(call))) + self._start_segments(segments, handles) + with self.runtime.span( + STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SEGMENT_WAIT) + ): + payloads = self._collect(handles) + # An Exception keeps its existing handling: a failed dispatch, a + # segment failure and a requested cancellation each cancel every + # started segment once before they are raised. except Exception: + raise + # Modal stops a cancelled input by raising InputCancellation, a + # BaseException, in it, and does not cancel the calls that input + # spawned, so this worker cancels them before it stops. + except BaseException: self._cancel_all(handles) raise - - payloads: list[object] | None = None - with self.runtime.span( - STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SEGMENT_WAIT) - ): - payloads = self._collect(handles) if payloads is None: msg = "Stage 12 segment wait returned no payloads" raise RuntimeError(msg) @@ -166,6 +171,35 @@ def run(self) -> SimulationCalculation: raise RuntimeError(msg) return merged + def _start_segments( + self, + segments: Sequence[Stage12SegmentInput], + handles: list[tuple[int, Any]], + ) -> None: + """Spawn every segment, adding each call to ``handles`` as soon as it starts. + + A failed dispatch cancels every segment already started, then is raised. + """ + try: + for segment in segments: + call = None + with self.runtime.span( + STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SEGMENT_DISPATCH), + attributes={"segment_index": segment.segment_index}, + ): + call = self.child_function.spawn( + segment.model_dump(mode="python"), + observability_context=self.runtime.capture_context(), + ) + # Keep the call before its span closes, so a stop while + # the span closes still cancels it. + if call is not None: + handles.append((segment.segment_index, call)) + _require_call(call) + except Exception: + self._cancel_all(handles) + raise + def _collect(self, handles: list[tuple[int, Any]]) -> list[object]: results: list[object | None] = [None] * len(handles) pending = set(range(len(handles))) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/output_planning.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/output_planning.py index a1b4d32aa..cddf401a5 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/output_planning.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/output_planning.py @@ -12,6 +12,11 @@ configure_labor_supply_response_variables, labor_supply_response_is_active, ) +from policyengine.outputs.extra_variables import add_extra_variables +from policyengine.outputs.labor_supply_response import ( + UK_ACTIVE_LSR_VARIABLES, + US_ACTIVE_LSR_VARIABLES, +) from policyengine_simulation_contract.stage12_execution import ( EntityOutputPlan, PlannedSimulationExecutionInput, @@ -224,6 +229,68 @@ def resolve_report_output_plan( ) +def _labor_supply_variables(country: CountryId) -> dict[str, list[str]]: + return US_ACTIVE_LSR_VARIABLES if country == "us" else UK_ACTIVE_LSR_VARIABLES + + +def resolve_simulation_output_plan( + simulation: SimulationExecutionInput, + *, + labor_supply_response: bool, +) -> Stage12OutputPlan: + """Resolve one v2 economy simulation's output schema, for any report it is in. + + A simulation run can be compared in many reports, so its columns are the + ones a report plans for it, every aggregate requested, without cliff + impacts. The labour supply response columns are the exception: a report + plans them for both simulations when either policy changes an + elasticity, and they are planned here only when ``labor_supply_response`` + asks for them, because calculating them in the UK runs the simulation + again. + """ + country = simulation.geography.country + model = _country_model(country) + planning = _planning_simulation(simulation, model=model) + if labor_supply_response: + add_extra_variables(planning, _labor_supply_variables(country)) + if country == "us": + from policyengine.tax_benefit_models.us.analysis import ( + configure_budgetary_impact_variables, + ) + + configure_budgetary_impact_variables(planning, planning) + aggregates = tuple(ReportAggregate) + variables = model.resolve_entity_variables(planning) + dataset_variables = _required_dataset_variables( + country=country, + region=simulation.geography.region, + aggregates=aggregates, + ) + extras = planning.extra_variables or {} + return Stage12OutputPlan( + country=country, + requirements=ReportOutputRequirements( + aggregates=aggregates, + include_cliff_impacts=False, + labor_supply_response_active=labor_supply_response, + ), + entities=tuple( + EntityOutputPlan( + entity=entity, + materialized_variables=tuple( + sorted( + set(variables.get(entity, ())) + | set(dataset_variables.get(entity, ())) + ) + ), + additional_variables=tuple(sorted(set(extras.get(entity, ())))), + dataset_variables=dataset_variables.get(entity, ()), + ) + for entity in sorted(set(variables) | set(dataset_variables)) + ), + ) + + def plan_simulation_input( simulation: SimulationExecutionInput, output_plan: Stage12OutputPlan, diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/report_coordination.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/report_coordination.py index 15c7ab7de..eebe8afdf 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/report_coordination.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/report_coordination.py @@ -4,11 +4,12 @@ import dataclasses import time +from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import UTC, datetime from functools import partial from hashlib import sha256 -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from uuid import uuid4 from policyengine_simulation_contract.stage12_execution import ( @@ -19,6 +20,7 @@ ComparisonSimulationRecord, PlannedSimulationExecutionInput, ReportExecutionInput, + ReportResultDelivery, SimulationArtifactDescriptor, SimulationRole, Stage12InvocationContext, @@ -44,11 +46,13 @@ ) from .comparison import ComparisonServices, compare_completed_report from .coordinated_simulations import ( + cancel_child_call, cancel_pending_simulations, cancellation, child_failure_detail, child_record, record_child_failure, + report_child_wait_failure, simulation_failure_attributes, ) from .dependencies import ChildCall, ChildInvoker, ChildSpawnRequest, ComparisonStore @@ -102,6 +106,18 @@ def _require_artifact_digest( raise ValueError(mismatch_message) +def _child_context_payload(context: Stage12InvocationContext) -> dict[str, Any]: + """Return the dispatch context a child simulation receives. + + A Stage 12 child receives exactly the fields it received before + ``result_delivery`` existed. A child of a report returned to its caller + receives the field, so that it reads and writes no Stage 12 rows either. + """ + if context.result_delivery is ReportResultDelivery.STAGE12_DATABASE: + return context.model_dump(mode="json", exclude={"result_delivery"}) + return context.model_dump(mode="json") + + @dataclass(frozen=True) class CoordinatorInvocation: """The deployed coordinator call running a report: its Modal app and call ID.""" @@ -117,7 +133,8 @@ class CoordinatorDependencies: The defaults are the Stage 12 database store, the private artifact bucket, Modal child invocation, the standard aggregate builder and output plan resolution, no telemetry runtime, the real clock and sleep, and the - standard child wait window. + standard child wait window. A coordinator whose report returns to its + caller never uses ``store``. """ store: ComparisonStore | None = None @@ -133,14 +150,20 @@ class CoordinatorDependencies: wait_timeout_seconds: float = SIMULATION_WAIT_TIMEOUT_SECONDS -@dataclass -class ReportCoordination: - """One claimed report's inputs and resolved seams, run through each phase.""" +@dataclass(kw_only=True) +class ReportSimulationCoordination(ABC): + """The steps a report coordinator runs however it delivers the report. + + It plans the report's output and both child inputs, starts each child, + waits for both within one shared window, and reads and checks their + artifacts. Subclasses decide what a failed or stopped child records: the + Stage 12 path writes its rows, and a report returned to its caller has no + rows to write. + """ report: ReportExecutionInput context: Stage12InvocationContext invocation: CoordinatorInvocation - persistence: ComparisonStore artifact_storage: Stage12ArtifactStore child_invoker: ChildInvoker dependencies: CoordinatorDependencies @@ -187,145 +210,58 @@ def stage_scope( stage, attributes, tracking=self.stage_tracking, operation=True ) - def run(self, parent: ComparisonReportRecord) -> dict[str, Any]: - """Plan, start and await both children, then aggregate and complete.""" - output_plan, planned_simulations = self._prepare(parent) - self.simulations = { - simulation.role.value: simulation for simulation in planned_simulations - } - children = self._create_children(planned_simulations) - descriptors, calls = self._start_children(planned_simulations, children) - # Every required call has been started before the coordinator observes - # baseline and reform together through nonblocking result probes. - self._wait_for_children(calls, descriptors) - return self._aggregate_and_complete(parent, output_plan, descriptors) + @abstractmethod + def _record_dispatch_failure( + self, + simulation: PlannedSimulationExecutionInput, + detail: Stage12FailureDetail, + ) -> None: + """Record that ``simulation`` could not be started, where there is a record.""" - def _prepare( - self, parent: ComparisonReportRecord - ) -> tuple[Stage12OutputPlan, tuple[PlannedSimulationExecutionInput, ...]]: - """Bind the run's artifact prefix, then plan the outputs and child inputs.""" - with self.stage_scope(Stage.STAGE12_COORDINATOR_PREPARATION): - self.context = self.context.model_copy( - update={ - "artifact_prefix": ( - f"stage-12-runs/{parent.environment}/" - f"{parent.created_at:%Y}/{parent.created_at:%m}/" - f"{parent.evaluation_id}" - ), - "created_at": parent.created_at, - "retention_expires_at": parent.retention_expires_at, - } - ) - with self.stage_scope( - Stage.STAGE12_OUTPUT_PLANNING, - {"country": self.report.baseline.geography.country}, - ): - resolver = self.dependencies.output_plan_resolver - if resolver is None: - output_plan = resolve_report_output_plan( - self.report, - stage_scope=self.stage_scope, - ) - else: - output_plan = resolver(self.report) - simulations = [] - for simulation in (self.report.baseline, self.report.reform): - with self.stage_scope( - Stage.STAGE12_CHILD_INPUT_PLANNING, - {"simulation_role": simulation.role.value}, - ): - simulations.append(plan_simulation_input(simulation, output_plan)) - return output_plan, tuple(simulations) + @abstractmethod + def _child_call_failed(self, role: str, error: Exception) -> Stage12FailureDetail: + """Report a failed child call and return the failure its report records.""" - def _create_children( - self, planned_simulations: tuple[PlannedSimulationExecutionInput, ...] - ) -> dict[SimulationRole, ComparisonSimulationRecord]: - """Create each child's row, or resolve the row an earlier attempt made.""" - with self.span(Stage.STAGE12_CHILD_STATE_CREATE): - return { - simulation.role: self.persistence.create_or_resolve_simulation( - child_record( - simulation=simulation, - context=self.context, - function_name=self.context.simulation_callable, - ) - ).record - for simulation in planned_simulations - } + @abstractmethod + def _stop_children(self, roles: set[str], *, reason: str) -> None: + """Stop each started, unfinished child in ``roles``. - def _start_children( - self, - planned_simulations: tuple[PlannedSimulationExecutionInput, ...], - children: dict[SimulationRole, ComparisonSimulationRecord], - ) -> tuple[dict[str, SimulationArtifactDescriptor], dict[str, ChildCall]]: - """Reuse finished children, reattach running ones and dispatch the rest.""" - descriptors = self.descriptors - calls = self.calls - for simulation in planned_simulations: - child = children[simulation.role] - role = simulation.role.value - if child.status is ComparisonRunLifecycleStatus.SUCCEEDED: - descriptors[role] = self._retained_descriptor(child, simulation) - elif ( - child.status is ComparisonRunLifecycleStatus.RUNNING - and child.modal_invocation_id is not None - and not child.modal_invocation_id.startswith("dispatch-pending-") - ): - calls[role] = self.child_invoker.restore(child.modal_invocation_id) - else: - calls[role] = self._dispatch_child(child, simulation) - return descriptors, calls + ``reason`` ends the summary a recorded cancellation carries. + """ - def _retained_descriptor( + def _plan_report_simulations( self, - child: ComparisonSimulationRecord, - simulation: PlannedSimulationExecutionInput, - ) -> SimulationArtifactDescriptor: - """Describe a succeeded child's digest-checked retained artifact.""" - descriptor = descriptor_from_record(child, simulation) - retained = self.artifact_storage.read(descriptor.artifact.uri) - _require_artifact_digest( - retained, - descriptor.artifact.content_sha256, - mismatch_message="retained child artifact digest mismatch", - ) - return descriptor.model_copy( - update={ - "artifact": descriptor.artifact.model_copy( - update={"size_bytes": len(retained)} - ), - "calculation_provenance": ( - deserialize_calculation_provenance(retained) - ), - } - ) + ) -> tuple[Stage12OutputPlan, tuple[PlannedSimulationExecutionInput, ...]]: + """Resolve the report's shared output plan and attach it to both inputs.""" + with self.stage_scope( + Stage.STAGE12_OUTPUT_PLANNING, + {"country": self.report.baseline.geography.country}, + ): + resolver = self.dependencies.output_plan_resolver + if resolver is None: + output_plan = resolve_report_output_plan( + self.report, + stage_scope=self.stage_scope, + ) + else: + output_plan = resolver(self.report) + simulations = [] + for simulation in (self.report.baseline, self.report.reform): + with self.stage_scope( + Stage.STAGE12_CHILD_INPUT_PLANNING, + {"simulation_role": simulation.role.value}, + ): + simulations.append(plan_simulation_input(simulation, output_plan)) + return output_plan, tuple(simulations) - def _dispatch_child( - self, - child: ComparisonSimulationRecord, - simulation: PlannedSimulationExecutionInput, - ) -> ChildCall: - """Mark the child dispatching, spawn it, and attach its Modal call ID. + def _spawn_child(self, simulation: PlannedSimulationExecutionInput) -> ChildCall: + """Start one child simulation, keep its call in ``calls`` and return it. - A spawn failure is reported and recorded on the child (unless it already - finished), every started peer is stopped, and Stage12ExecutionError is - raised with the failure. + The call is kept before the dispatch span closes, so a stop while the + span closes still finds it. A spawn failure is reported, recorded + through ``_record_dispatch_failure``, every started child is stopped, + and Stage12ExecutionError is raised with the failure. """ - dispatching_at = datetime.now(UTC) - dispatch_placeholder = f"dispatch-pending-{uuid4()}" - self.persistence.replace_simulation( - child.model_copy( - update={ - "status": ComparisonRunLifecycleStatus.RUNNING, - "modal_invocation_id": dispatch_placeholder, - "started_at": child.started_at or dispatching_at, - "updated_at": dispatching_at, - "error_code": None, - "error_summary": None, - "completed_at": None, - } - ) - ) try: with self.span( Stage.STAGE12_CHILD_DISPATCH, @@ -340,10 +276,11 @@ def _dispatch_child( function_name=self.context.simulation_callable, environment=self.context.modal_environment, simulation=simulation.model_dump(mode="json"), - context=self.context.model_dump(mode="json"), + context=_child_context_payload(self.context), observability_context=child_observability_context, ) ) + self.calls[simulation.role.value] = call except Exception as error: # noqa: BLE001 # any dispatch failure is recorded on the child and stops its started peers detail = report_failure( error, @@ -355,28 +292,12 @@ def _dispatch_child( runtime=self.runtime, progress=self.progress, ) - record_child_failure( - store=self.persistence, - simulation=simulation, - detail=detail, - ) - cancel_pending_simulations( - calls=self.calls, - simulations=self.simulations, - roles=set(self.calls), - store=self.persistence, - detail=cancellation(f"the {simulation.role.value} simulation failed"), + self._record_dispatch_failure(simulation, detail) + self._stop_children( + set(self.calls), + reason=f"the {simulation.role.value} simulation failed", ) raise Stage12ExecutionError(detail) from None - # Record the call before attaching its ID, so a failed attach still - # stops it. - self.calls[simulation.role.value] = call - self.persistence.attach_simulation_invocation( - simulation.simulation_execution_id, - expected_placeholder=dispatch_placeholder, - modal_invocation_id=call.object_id, - updated_at=datetime.now(UTC), - ) return call def _wait_for_children( @@ -419,19 +340,10 @@ def _observe_children( except TimeoutError: continue except Exception as error: # noqa: BLE001 # a child call's failure can be any exception type; it is recorded and stops the other child - detail = child_failure_detail( - error=error, - simulation=self.simulations[role], - store=self.persistence, - runtime=self.runtime, - progress=self.progress, - ) - cancel_pending_simulations( - calls=calls, - simulations=self.simulations, - roles=pending - {role}, - store=self.persistence, - detail=cancellation(f"the {role} simulation failed"), + detail = self._child_call_failed(role, error) + self._stop_children( + pending - {role}, + reason=f"the {role} simulation failed", ) raise Stage12ExecutionError(detail) from None descriptors[role] = descriptor @@ -456,14 +368,9 @@ def _observe_children( runtime=self.runtime, failure_scope="child_wait_timeout", ) - cancel_pending_simulations( - calls=calls, - simulations=self.simulations, - roles=pending, - store=self.persistence, - detail=cancellation( - f"the {timeout_seconds:g}-second wait window closed" - ), + self._stop_children( + pending, + reason=f"the {timeout_seconds:g}-second wait window closed", ) raise wait_timeout # Never sleep past the deadline, so the window closes on time. @@ -515,6 +422,229 @@ def _read_retained_outputs( uk_local_authority_metadata, ) + def _cancel_through_modal(self, roles: set[str]) -> None: + """Ask Modal to stop each started child in ``roles``, logging any failure.""" + for role in sorted(roles): + cancel_child_call(self.calls[role], self.simulations[role]) + + def _report_failed_child_call( + self, + role: str, + error: Exception, + ) -> Stage12FailureDetail: + """Report the failed call of the child in ``role`` once; return its detail.""" + return report_child_wait_failure( + error, + simulation=self.simulations[role], + runtime=self.runtime, + progress=self.progress, + ) + + def _failure_detail(self, error: Exception) -> Stage12FailureDetail: + """Return the redacted failure of a failed run, reporting it once. + + Child, dispatch and wait-timeout failures raise Stage12ExecutionError + only after reporting the original failure and stopping their unfinished + peers. Any other failure is reported here, before the caller records + or raises it. If it occurred while a child was still running, such as + a failed placeholder write or invocation attach on the Stage 12 path, + every started, unfinished child is then stopped through + ``_stop_children`` so none keeps running for a failed report. + """ + if isinstance(error, Stage12ExecutionError): + return error.detail + detail = report_failure( + error, + FailureSite( + scope="report_coordination", + default_code="report_coordination_failed", + attributes={"evaluation_id": str(self.report.evaluation_id)}, + ), + runtime=self.runtime, + progress=self.progress, + ) + self._stop_children( + set(self.calls) - set(self.descriptors), + reason="the coordinator failed", + ) + return detail + + +@dataclass(kw_only=True) +class ReportCoordination(ReportSimulationCoordination): + """One claimed Stage 12 report's inputs and resolved seams, run through each phase. + + Every child's state, and the report's, is recorded in ``persistence``. + """ + + persistence: ComparisonStore + + def run(self, parent: ComparisonReportRecord) -> dict[str, Any]: + """Plan, start and await both children, then aggregate and complete.""" + output_plan, planned_simulations = self._prepare(parent) + self.simulations = { + simulation.role.value: simulation for simulation in planned_simulations + } + children = self._create_children(planned_simulations) + descriptors, calls = self._start_children(planned_simulations, children) + # Every required call has been started before the coordinator observes + # baseline and reform together through nonblocking result probes. + self._wait_for_children(calls, descriptors) + return self._aggregate_and_complete(parent, output_plan, descriptors) + + def _prepare( + self, parent: ComparisonReportRecord + ) -> tuple[Stage12OutputPlan, tuple[PlannedSimulationExecutionInput, ...]]: + """Bind the run's artifact prefix, then plan the outputs and child inputs.""" + with self.stage_scope(Stage.STAGE12_COORDINATOR_PREPARATION): + self.context = self.context.model_copy( + update={ + "artifact_prefix": ( + f"stage-12-runs/{parent.environment}/" + f"{parent.created_at:%Y}/{parent.created_at:%m}/" + f"{parent.evaluation_id}" + ), + "created_at": parent.created_at, + "retention_expires_at": parent.retention_expires_at, + } + ) + return self._plan_report_simulations() + + def _create_children( + self, planned_simulations: tuple[PlannedSimulationExecutionInput, ...] + ) -> dict[SimulationRole, ComparisonSimulationRecord]: + """Create each child's row, or resolve the row an earlier attempt made.""" + with self.span(Stage.STAGE12_CHILD_STATE_CREATE): + return { + simulation.role: self.persistence.create_or_resolve_simulation( + child_record( + simulation=simulation, + context=self.context, + function_name=self.context.simulation_callable, + ) + ).record + for simulation in planned_simulations + } + + def _start_children( + self, + planned_simulations: tuple[PlannedSimulationExecutionInput, ...], + children: dict[SimulationRole, ComparisonSimulationRecord], + ) -> tuple[dict[str, SimulationArtifactDescriptor], dict[str, ChildCall]]: + """Reuse finished children, reattach running ones and dispatch the rest.""" + descriptors = self.descriptors + calls = self.calls + for simulation in planned_simulations: + child = children[simulation.role] + role = simulation.role.value + if child.status is ComparisonRunLifecycleStatus.SUCCEEDED: + descriptors[role] = self._retained_descriptor(child, simulation) + elif ( + child.status is ComparisonRunLifecycleStatus.RUNNING + and child.modal_invocation_id is not None + and not child.modal_invocation_id.startswith("dispatch-pending-") + ): + calls[role] = self.child_invoker.restore(child.modal_invocation_id) + else: + calls[role] = self._dispatch_child(child, simulation) + return descriptors, calls + + def _retained_descriptor( + self, + child: ComparisonSimulationRecord, + simulation: PlannedSimulationExecutionInput, + ) -> SimulationArtifactDescriptor: + """Describe a succeeded child's digest-checked retained artifact.""" + descriptor = descriptor_from_record(child, simulation) + retained = self.artifact_storage.read(descriptor.artifact.uri) + _require_artifact_digest( + retained, + descriptor.artifact.content_sha256, + mismatch_message="retained child artifact digest mismatch", + ) + return descriptor.model_copy( + update={ + "artifact": descriptor.artifact.model_copy( + update={"size_bytes": len(retained)} + ), + "calculation_provenance": ( + deserialize_calculation_provenance(retained) + ), + } + ) + + def _dispatch_child( + self, + child: ComparisonSimulationRecord, + simulation: PlannedSimulationExecutionInput, + ) -> ChildCall: + """Mark the child dispatching, spawn it, and attach its Modal call ID. + + A spawn failure is reported and recorded on the child (unless it already + finished), every started peer is stopped, and Stage12ExecutionError is + raised with the failure. + """ + dispatching_at = datetime.now(UTC) + dispatch_placeholder = f"dispatch-pending-{uuid4()}" + self.persistence.replace_simulation( + child.model_copy( + update={ + "status": ComparisonRunLifecycleStatus.RUNNING, + "modal_invocation_id": dispatch_placeholder, + "started_at": child.started_at or dispatching_at, + "updated_at": dispatching_at, + "error_code": None, + "error_summary": None, + "completed_at": None, + } + ) + ) + # _spawn_child keeps the call in calls, so a failed attach still stops + # it. + call = self._spawn_child(simulation) + self.persistence.attach_simulation_invocation( + simulation.simulation_execution_id, + expected_placeholder=dispatch_placeholder, + modal_invocation_id=call.object_id, + updated_at=datetime.now(UTC), + ) + return call + + @override + def _record_dispatch_failure( + self, + simulation: PlannedSimulationExecutionInput, + detail: Stage12FailureDetail, + ) -> None: + """Record the dispatch failure on the child, unless it already finished.""" + record_child_failure( + store=self.persistence, + simulation=simulation, + detail=detail, + ) + + @override + def _child_call_failed(self, role: str, error: Exception) -> Stage12FailureDetail: + """Report the failed call; the failure its worker recorded takes precedence.""" + return child_failure_detail( + error=error, + simulation=self.simulations[role], + store=self.persistence, + runtime=self.runtime, + progress=self.progress, + ) + + @override + def _stop_children(self, roles: set[str], *, reason: str) -> None: + """Record each unfinished child as cancelled, then stop it.""" + cancel_pending_simulations( + calls=self.calls, + simulations=self.simulations, + roles=roles, + store=self.persistence, + detail=cancellation(reason), + ) + def _aggregate_and_complete( self, parent: ComparisonReportRecord, @@ -578,33 +708,10 @@ def _aggregate_and_complete( def fail(self, parent: ComparisonReportRecord, error: Exception) -> None: """Record a failed run once, stop its unfinished children, fail the parent. - Child, dispatch and wait-timeout failures raise Stage12ExecutionError - only after reporting the original failure and stopping their unfinished - peers. Any other failure is reported here, once, before any database - write. If it occurred after a child started, such as a failed - placeholder write or invocation attach, every started, unfinished child - is then stopped so none keeps running for a failed report. + See ``_failure_detail`` for how the failure is reported and which + children are stopped. """ - if isinstance(error, Stage12ExecutionError): - detail = error.detail - else: - detail = report_failure( - error, - FailureSite( - scope="report_coordination", - default_code="report_coordination_failed", - attributes={"evaluation_id": str(self.report.evaluation_id)}, - ), - runtime=self.runtime, - progress=self.progress, - ) - cancel_pending_simulations( - calls=self.calls, - simulations=self.simulations, - roles=set(self.calls) - set(self.descriptors), - store=self.persistence, - detail=cancellation("the coordinator failed"), - ) + detail = self._failure_detail(error) record_failure_cause(self.runtime, error, detail.error_summary) failed_at = datetime.now(UTC) persist_failure_record( diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/returned_coordination.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/returned_coordination.py new file mode 100644 index 000000000..764850938 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/returned_coordination.py @@ -0,0 +1,256 @@ +"""Coordination of a report whose result returns to its caller. + +The coordinator plans, starts, awaits and checks the report's baseline and +reform simulations with the same steps as the Stage 12 coordinator +(``ReportSimulationCoordination``), but reads and writes no Stage 12 rows. It +returns a ``ReportExecutionResult`` with the aggregate inline, or raises +``ReportExecutionError``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, override + +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.report_execution import ( + ReportExecutionError, + ReportExecutionProvenance, + ReportExecutionResult, + report_aggregate_sha256, +) +from policyengine_simulation_contract.stage12_execution import ( + AggregateReportArtifactPayload, + ReportExecutionInput, + ReportResultDelivery, + Stage12InvocationContext, +) +from policyengine_simulation_observability.stages import ( + STAGE12_CANONICAL_REPORT_STAGES, + Stage, +) + +from policyengine_simulation_executor.stage12_artifacts import comparison_run_prefix + +from .coordination import ( + CoordinatorDependencies, + CoordinatorInvocation, + ReportSimulationCoordination, + validate_dispatch_context, +) +from .dependencies import ModalChildInvoker, artifact_store +from .failure_causes import StageProgress, record_failure_cause +from .failures import failure_detail_from_record, report_execution_error + +if TYPE_CHECKING: + from policyengine_simulation_contract.stage12_execution import ( + PlannedSimulationExecutionInput, + Stage12OutputPlan, + ) + + from .failures import Stage12FailureDetail + + +@dataclass(kw_only=True) +class _ReturnedReportCoordination(ReportSimulationCoordination): + """One report whose result returns to its caller, run through each phase. + + No Stage 12 row exists for the report or its children, so nothing records + their state: a failed child's own ``ReportExecutionError`` carries its + failure, and a child that must stop is cancelled through Modal. + """ + + def run(self) -> ReportExecutionResult: + """Plan, start and await both children, then aggregate and check the result.""" + with self.stage_scope(Stage.STAGE12_COORDINATOR_PREPARATION): + self.context = self.context.model_copy( + update={ + "artifact_prefix": comparison_run_prefix( + environment=self.context.environment, + created_at=self.context.created_at, + evaluation_id=self.report.evaluation_id, + ) + } + ) + output_plan, planned_simulations = self._plan_report_simulations() + self.simulations = { + simulation.role.value: simulation for simulation in planned_simulations + } + for simulation in planned_simulations: + self._spawn_child(simulation) + # Both children have started before the coordinator observes them + # together through nonblocking result probes. + self._wait_for_children(self.calls, self.descriptors) + baseline, reform, uk_local_authority_metadata = self._read_retained_outputs( + output_plan, self.descriptors["baseline"], self.descriptors["reform"] + ) + with self.span(Stage.STAGE12_AGGREGATION): + aggregate = self.dependencies.aggregator( + report=self.report, + baseline=baseline, + reform=reform, + uk_local_authority_metadata=uk_local_authority_metadata, + ) + return self._result(output_plan, aggregate) + + def _result( + self, + output_plan: Stage12OutputPlan, + aggregate: dict[str, Any], + ) -> ReportExecutionResult: + """Check the aggregate and build the result this report delivers. + + The aggregate passes the checks a Stage 12 aggregate artifact write + applies, must describe this report, and its result must be a + ``SingleYearMacroOutput``. + """ + payload = AggregateReportArtifactPayload.model_validate(aggregate) + if ( + payload.evaluation_id != self.report.evaluation_id + or payload.bundle != self.report.baseline.bundle + or payload.requested_aggregates != self.report.requested_aggregates + ): + msg = "aggregate does not describe this report" + raise ValueError(msg) + totals = SingleYearMacroOutput.model_validate(payload.result) + return ReportExecutionResult( + evaluation_id=self.report.evaluation_id, + requested_aggregates=self.report.requested_aggregates, + aggregate=totals, + aggregate_sha256=report_aggregate_sha256(totals), + output_plan=output_plan, + baseline=self.descriptors["baseline"], + reform=self.descriptors["reform"], + provenance=ReportExecutionProvenance( + bundle=self.report.baseline.bundle, + worker_version=self.context.worker_version, + modal_environment=self.context.modal_environment, + modal_application=self.context.modal_application, + simulation_callable=self.context.simulation_callable, + version_manifest_sha256=self.context.version_manifest_sha256, + coordinator_invocation_id=self.invocation.invocation_id, + ), + completed_at=datetime.now(UTC), + ) + + def fail(self, error: Exception) -> ReportExecutionError: + """Return the typed error for a failed run, reporting the failure once. + + See ``_failure_detail`` for how the failure is reported and which + children are stopped. The redacted failure is also recorded as the + operation's cause. + """ + detail = self._failure_detail(error) + record_failure_cause(self.runtime, error, detail.error_summary) + return report_execution_error(self.report.evaluation_id, detail) + + def stop_unfinished_children(self) -> None: + """Cancel every started, unfinished child through Modal. + + The coordinator does this when it stops without a failure to report, + as when Modal cancels its own input: Modal does not cancel the calls + that input spawned. + """ + self._stop_children( + set(self.calls) - set(self.descriptors), + reason="the coordinator stopped", + ) + + @override + def _record_dispatch_failure( + self, + simulation: PlannedSimulationExecutionInput, + detail: Stage12FailureDetail, + ) -> None: + """Record nothing: a returned report's children have no record.""" + + @override + def _child_call_failed(self, role: str, error: Exception) -> Stage12FailureDetail: + """Report the failed call; the child's own ``ReportExecutionError`` wins. + + That error carries the failure its worker reported, as a Stage 12 + worker's recorded failure does. + """ + detail = self._report_failed_child_call(role, error) + if isinstance(error, ReportExecutionError): + return failure_detail_from_record( + error_code=error.failure.error_code, + error_summary=error.failure.error_summary, + ) + return detail + + @override + def _stop_children(self, roles: set[str], *, reason: str) -> None: + """Cancel each child through Modal: no record can tell it to stop. + + Modal raises its ``InputCancellation`` in the child, and a segmented US + child then cancels the region-group segment calls it started + (``Stage12SegmentRunner.run``), since Modal does not cancel them. + """ + self._cancel_through_modal(roles) + + +def coordinate_report_for_caller( + payload: object, + context_payload: object, + *, + invocation: CoordinatorInvocation, + dependencies: CoordinatorDependencies | None = None, +) -> dict[str, Any]: + """Run one report whose result returns to its caller; return the result payload. + + The payload is a ``ReportExecutionResult`` in JSON mode. No Stage 12 row is + read or written and ``dependencies.store`` is never used. Nothing claims + the report, so a repeated dispatch runs it again, and the caller + deduplicates. Artifacts go under the Stage 12 layout for the context's + environment, creation time and report, whatever prefix the caller sent. + Both children start with the same delivery, so they write no rows either, + and each names its input and output by their digests. A repeated dispatch, + or a call Modal restarts after pre-empting it, therefore stores its + artifacts beside any an earlier attempt stored, even where it plans or + calculates differently. No aggregate artifact is written: the aggregate is + returned inline. + + A context that does not ask for the report to return to its caller, or + that disagrees with the report, raises ``ValueError`` before any child + starts. Any later failure stops the unfinished children through Modal, is + reported once, and is raised as ``ReportExecutionError`` with the redacted + code and summary a Stage 12 report row would record. When the coordinator + stops on a ``BaseException`` that is not an ``Exception``, such as the + ``InputCancellation`` Modal raises in a cancelled input, it cancels the + unfinished children through Modal and re-raises it unreported. + """ + dependencies = dependencies or CoordinatorDependencies() + # Open the shared child-wait window before any setup work. + child_wait_deadline = dependencies.monotonic() + dependencies.wait_timeout_seconds + report = ReportExecutionInput.model_validate(payload) + context = Stage12InvocationContext.model_validate(context_payload) + if context.result_delivery is not ReportResultDelivery.RETURN_TO_CALLER: + msg = "coordinate_report_for_caller requires result_delivery return_to_caller" + raise ValueError(msg) + validate_dispatch_context(report, context, invocation.application_name) + coordination = _ReturnedReportCoordination( + report=report, + context=context, + invocation=invocation, + artifact_storage=dependencies.artifacts or artifact_store(), + child_invoker=dependencies.invoker or ModalChildInvoker(), + dependencies=dependencies, + # A returned report cannot be compared with a production job. + stage_plan=STAGE12_CANONICAL_REPORT_STAGES, + progress=StageProgress(), + child_wait_deadline=child_wait_deadline, + ) + try: + result = coordination.run() + # Report a redacted cause for any coordinator, child-call, aggregation or + # artifact failure, and return it to the caller as a typed error. + except Exception as error: # noqa: BLE001 # every failure reaches the caller as a typed error with its redacted detail + raise coordination.fail(error) from None + # Modal stops a cancelled coordinator by raising InputCancellation, a + # BaseException, in it. + except BaseException: + coordination.stop_unfinished_children() + raise + return result.model_dump(mode="json") diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py index 1975fc8e0..9e2651d38 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py @@ -6,7 +6,6 @@ from collections.abc import Callable, Mapping from contextlib import nullcontext from dataclasses import dataclass -from datetime import UTC, datetime from functools import partial from hashlib import sha256 from typing import TYPE_CHECKING, Any @@ -14,16 +13,12 @@ import pandas as pd from policyengine.core import Simulation from policyengine_simulation_contract.stage12_execution import ( - ArtifactMediaType, - ArtifactReference, ComparisonRunLifecycleStatus, - ComparisonSimulationRecord, PlannedSimulationExecutionInput, - RowIdentity, + ReportResultDelivery, SimulationArtifactDescriptor, SimulationExecutionInput, Stage12InvocationContext, - stage12_output_plan_sha256, ) from policyengine_simulation_observability.stages import ( STAGE12_SIMULATION_STAGES, @@ -41,6 +36,14 @@ ) from .cached_simulation import Stage12CachedSimulation, qualifying_cache_identity +from .catalog_population import ( + CatalogDatasetChoice, + CatalogRegion, + catalog_dataset, + catalog_population, + resolve_catalog_dataset, + resolve_catalog_region, +) from .dependencies import ComparisonStore, artifact_store, runtime_store from .failure_causes import ( StageProgress, @@ -48,25 +51,33 @@ record_failure_cause, tracked_stage, ) -from .failure_recording import FailureSite, persist_failure_record, report_failure from .failures import ( Stage12CancellationError, - Stage12ExecutionError, - Stage12FailureDetail, + report_execution_error, validate_policy_periods, ) from .output_planning import apply_output_plan, validate_output_frames from .partition import should_segment_simulation +from .simulation_records import ( + cancellation_check, + claim_simulation, + descriptor_from_record, + record_failure, + record_success, + simulation_failure_detail, +) if TYPE_CHECKING: from contextlib import AbstractContextManager from types import ModuleType - from uuid import UUID from policyengine.core import Dataset from policyengine_observability import ObservabilityRuntime from policyengine_simulation_contract.spm import SPMProvenance - from policyengine_simulation_contract.stage12_bundle import CountryId + from policyengine_simulation_contract.stage12_bundle import ( + CountryId, + Stage12CountryBundle, + ) from policyengine_simulation_contract.uk_geography import UKLocalAuthorityMetadata from policyengine_simulation_executor.simulation_runtime import ( @@ -76,6 +87,15 @@ logger = logging.getLogger(__name__) +__all__ = [ + "SimulationCalculation", + "SimulationWorkerDependencies", + "calculate_simulation_frames", + "descriptor_from_record", + "run_single_simulation", + "simulation_input_sha256", +] + @dataclass(frozen=True) class SimulationCalculation: @@ -194,15 +214,24 @@ def _require_installed_bundle(simulation: SimulationExecutionInput) -> None: for item in resolved.bundle.countries if item.country == simulation.geography.country ) - # A region loads the dataset the bundle names for its region type. Entry - # services released before regional provenance name the default dataset - # for every region, so the default stays accepted until they are updated. - regional = country.dataset_for_region(simulation.geography.region) + # A v2 economy run loads the dataset its catalog population names. A + # Stage 12 region loads the dataset the bundle names for its region type. + # Entry services released before regional provenance name the default + # dataset for every region, so the default stays accepted until they are + # updated. + population = catalog_population(simulation) + regional = ( + country.dataset_for_region(simulation.geography.region) + if population is None + else catalog_dataset(country, population.dataset_name).identity + ) named = simulation.bundle.dataset.identity regional_entry = next( (item for item in country.datasets if item.identity == regional), None ) - if regional_entry is not None and named == regional_entry.identity: + if regional_entry is not None and ( + population is not None or named == regional_entry.identity + ): expected_dataset = { "dataset_identity": regional_entry.identity, "dataset_uri": regional_entry.uri, @@ -231,6 +260,37 @@ def _require_installed_bundle(simulation: SimulationExecutionInput) -> None: raise RuntimeError(msg) +def _installed_country_bundle(country: CountryId) -> Stage12CountryBundle: + """Return the installed release's bundle entry for ``country``.""" + return next( + item + for item in load_stage12_bundle().bundle.countries + if item.country == country + ) + + +def _region_resolution(region: CatalogRegion) -> RegionResolution: + """Hand a catalog region to the calculation as the region type it reads.""" + from policyengine_simulation_executor.simulation_runtime import ( # noqa: PLC0415 # resolved per call, as the calculation's other helpers are + RegionResolution, + ) + + return RegionResolution( + code=region.code, + scoping_strategy=region.scoping_strategy, + state_code=region.state_code, + ) + + +def _dataset_selection(choice: CatalogDatasetChoice) -> DatasetSelection: + """Hand a catalog dataset to the calculation as the selection type it reads.""" + from policyengine_simulation_executor.simulation_runtime import ( # noqa: PLC0415 # resolved per call, as the calculation's other helpers are + DatasetSelection, + ) + + return DatasetSelection(choice.name, choice.uri, choice.is_default) + + def _require_loaded_dataset_matches_provenance( simulation: SimulationExecutionInput, selection: DatasetSelection ) -> None: @@ -301,16 +361,31 @@ def calculate_simulation_frames( ): with stage_span(Stage.COUNTRY_MODULE_LOAD): country_module = _country_module(country) + # A v2 economy run's catalog population names its region's filter and + # dataset; a Stage 12 run looks its region up in the registry. + population = catalog_population(simulation) with stage_span(Stage.REGION_RESOLUTION): - region = resolve_region( - country_module=country_module, - country=country, - params=params, + region = ( + resolve_region( + country_module=country_module, + country=country, + params=params, + ) + if population is None + else _region_resolution(resolve_catalog_region(simulation, population)) ) with stage_span(Stage.DATASET_RESOLUTION): - dataset_selection = _resolve_dataset_selection( - params, - region_resolution=region, + dataset_selection = ( + _resolve_dataset_selection( + params, + region_resolution=region, + ) + if population is None + else _dataset_selection( + resolve_catalog_dataset( + _installed_country_bundle(country), population + ) + ) ) _require_loaded_dataset_matches_provenance(simulation, dataset_selection) with stage_span(Stage.DATASET_LOAD): @@ -383,90 +458,6 @@ def calculate_simulation_frames( ) -def descriptor_from_record( - child: ComparisonSimulationRecord, - simulation: PlannedSimulationExecutionInput, -) -> SimulationArtifactDescriptor: - """Rebuild the artifact descriptor of a child that already succeeded. - - ``size_bytes`` is 0 because the record does not store it; raises - ``ValueError`` if the record lacks any output metadata. - """ - output_uri = child.output_uri - output_sha256 = child.output_sha256 - output_schema_version = child.output_schema_version - row_identity_columns = child.row_identity_columns - row_count = child.row_count - row_identity_sha256 = child.row_identity_sha256 - if ( - output_uri is None - or output_sha256 is None - or output_schema_version != 1 - or row_identity_columns is None - or row_count is None - or row_identity_sha256 is None - ): - msg = "successful child record has incomplete output metadata" - raise ValueError(msg) - - return SimulationArtifactDescriptor( - evaluation_id=simulation.evaluation_id, - simulation_execution_id=simulation.simulation_execution_id, - role=simulation.role, - artifact=ArtifactReference( - uri=output_uri, - media_type=ArtifactMediaType.PARQUET, - content_sha256=output_sha256, - size_bytes=0, - ), - output_schema_version=1, - output_plan_sha256=stage12_output_plan_sha256(simulation.output_plan), - row_identity=RowIdentity( - identifier_columns=row_identity_columns, - row_count=row_count, - identity_sha256=row_identity_sha256, - ), - bundle=simulation.bundle, - ) - - -def _require_completion_recorded(*, replaced: bool) -> None: - """Raise ``Stage12CancellationError`` when the completion write replaced nothing. - - The coordinator records a cancelled simulation as failed while it is still - running, so a write that finds the record no longer running means this - simulation was cancelled. - """ - if not replaced: - msg = "Stage 12 simulation was cancelled" - raise Stage12CancellationError(msg) - - -def _mark_running_simulation_failed( - persistence: ComparisonStore, - simulation_execution_id: UUID, - *, - detail: Stage12FailureDetail, - failed_at: datetime, -) -> None: - """Record a failure only while the simulation is still running.""" - latest = persistence.get_simulation(simulation_execution_id) - if latest.status is not ComparisonRunLifecycleStatus.RUNNING: - return - persistence.replace_simulation_if_status( - latest.model_copy( - update={ - "status": ComparisonRunLifecycleStatus.FAILED, - "error_code": detail.error_code, - "error_summary": detail.error_summary, - "updated_at": failed_at, - "completed_at": failed_at, - } - ), - expected_status=ComparisonRunLifecycleStatus.RUNNING, - ) - - @dataclass(frozen=True) class SimulationWorkerDependencies: """Seams the single-simulation worker uses; unset seams use the defaults. @@ -492,6 +483,72 @@ class SimulationWorkerDependencies: runtime: ObservabilityRuntime | None = None +@dataclass(frozen=True) +class _SimulationRun: + """One planned simulation with its context, artifact store, seams and progress. + + ``progress`` records the stage the run is in, so a failure names it. + """ + + simulation: PlannedSimulationExecutionInput + context: Stage12InvocationContext + artifact_storage: Stage12ArtifactStore + dependencies: SimulationWorkerDependencies + progress: StageProgress + + def calculate_and_store( + self, + cancellation_requested: Callable[[], bool], + ) -> SimulationArtifactDescriptor: + """Check the policy, store the input, calculate, and store the output artifact. + + An eligible US simulation goes to the segmented calculator, which + checks ``cancellation_requested`` while it waits for its segments. + """ + simulation = self.simulation + stage_span = partial( + tracked_stage, + tracking=StageTracking( + STAGE12_SIMULATION_STAGES, self.progress, self.dependencies.runtime + ), + ) + # Modal restarts a pre-empted call, possibly on another machine whose + # calculation differs in its last bits, or on a redeployed application + # that plans the simulation differently. A returned simulation names + # its input and output by their digests, so it never needs a key that + # already holds other data. + returned = self.context.result_delivery is ReportResultDelivery.RETURN_TO_CALLER + write_input = ( + self.artifact_storage.write_input_named_by_digest + if returned + else self.artifact_storage.write_input + ) + write_simulation = ( + self.artifact_storage.write_simulation_named_by_digest + if returned + else self.artifact_storage.write_simulation + ) + validate_policy_periods(simulation.policy) + with stage_span(Stage.STAGE12_INPUT_WRITE): + write_input(prefix=self.context.artifact_prefix, simulation=simulation) + calculation = _calculate( + simulation, + self.dependencies, + progress=self.progress, + stage_span=stage_span, + cancellation_requested=cancellation_requested, + ) + validate_output_frames(calculation.frames, simulation.output_plan) + with stage_span(Stage.STAGE12_SIMULATION_ARTIFACT_WRITE): + return write_simulation( + prefix=self.context.artifact_prefix, + simulation=simulation, + frames=calculation.frames, + calculation_provenance=calculation.calculation_provenance, + uk_local_authority_metadata=calculation.uk_local_authority_metadata, + ) + + def run_single_simulation( payload: object, context_payload: object, @@ -504,48 +561,44 @@ def run_single_simulation( A child already marked succeeded returns its stored descriptor without recalculating. A failure after the run is marked running marks it FAILED and raises a ``RuntimeError`` that carries no calculation detail. + + When the context's ``result_delivery`` returns the report to its caller, + no Stage 12 row is read or written and ``store`` is not used; a failure + raises ``ReportExecutionError`` with its redacted detail instead (see + ``_run_simulation_for_caller``). """ dependencies = dependencies or SimulationWorkerDependencies() runtime = dependencies.runtime simulation = PlannedSimulationExecutionInput.model_validate(payload) context = Stage12InvocationContext.model_validate(context_payload) _require_context(simulation, context, required_country=required_country) + if context.result_delivery is ReportResultDelivery.RETURN_TO_CALLER: + return _run_simulation_for_caller( + _SimulationRun( + simulation=simulation, + context=context, + artifact_storage=dependencies.artifacts or artifact_store(), + dependencies=dependencies, + progress=StageProgress(role=simulation.role.value), + ) + ) persistence = dependencies.store or runtime_store(runtime) artifact_storage = dependencies.artifacts or artifact_store() child = persistence.get_simulation(simulation.simulation_execution_id) if child.status is ComparisonRunLifecycleStatus.SUCCEEDED: return descriptor_from_record(child, simulation).model_dump(mode="json") - running = _claim_simulation(persistence, child) - cancellation_requested = _cancellation_check(persistence, simulation) - progress = StageProgress(role=simulation.role.value) - stage_span = partial( - tracked_stage, - tracking=StageTracking(STAGE12_SIMULATION_STAGES, progress, runtime), + running = claim_simulation(persistence, child) + cancellation_requested = cancellation_check(persistence, simulation) + run = _SimulationRun( + simulation=simulation, + context=context, + artifact_storage=artifact_storage, + dependencies=dependencies, + progress=StageProgress(role=simulation.role.value), ) try: - validate_policy_periods(simulation.policy) - with stage_span(Stage.STAGE12_INPUT_WRITE): - artifact_storage.write_input( - prefix=context.artifact_prefix, - simulation=simulation, - ) - calculation = _calculate( - simulation, - dependencies, - progress=progress, - stage_span=stage_span, - cancellation_requested=cancellation_requested, - ) - validate_output_frames(calculation.frames, simulation.output_plan) - with stage_span(Stage.STAGE12_SIMULATION_ARTIFACT_WRITE): - descriptor = artifact_storage.write_simulation( - prefix=context.artifact_prefix, - simulation=simulation, - frames=calculation.frames, - calculation_provenance=calculation.calculation_provenance, - uk_local_authority_metadata=calculation.uk_local_authority_metadata, - ) - _record_success(persistence, running, descriptor) + descriptor = run.calculate_and_store(cancellation_requested) + record_success(persistence, running, descriptor) return descriptor.model_dump(mode="json") except Stage12CancellationError: msg = "Stage 12 simulation was cancelled" @@ -553,71 +606,47 @@ def run_single_simulation( # Persist a redacted cause for any country-package calculation failure # before returning a stable exception to Modal. except Exception as error: # noqa: BLE001 # every calculation failure is persisted before a stable exception returns to Modal - _record_failure( + record_failure( error, simulation, persistence=persistence, runtime=runtime, - progress=progress, + progress=run.progress, ) msg = "Stage 12 simulation execution failed" raise RuntimeError(msg) from None -def _claim_simulation( - persistence: ComparisonStore, - child: ComparisonSimulationRecord, -) -> ComparisonSimulationRecord: - """Mark an active simulation running and return its record, or refuse it. +def _never_cancelled() -> bool: + """Report no cancellation: nothing records one for a returned simulation.""" + return False - The claim is a conditional write, so a simulation whose record changed - meanwhile, for example because the coordinator cancelled it, is not run. - """ - if child.status not in { - ComparisonRunLifecycleStatus.PENDING, - ComparisonRunLifecycleStatus.RUNNING, - }: - msg = "Stage 12 simulation is no longer active" - raise RuntimeError(msg) - started = datetime.now(UTC) - running = child.model_copy( - update={ - "status": ComparisonRunLifecycleStatus.RUNNING, - "started_at": child.started_at or started, - "updated_at": started, - "error_code": None, - "error_summary": None, - } - ) - running, claimed = persistence.replace_simulation_if_status( - running, - expected_status=child.status, - ) - if not claimed: - msg = "Stage 12 simulation was cancelled" - raise RuntimeError(msg) - return running +def _run_simulation_for_caller(run: _SimulationRun) -> dict[str, Any]: + """Calculate one simulation of a report returned to its caller. -def _cancellation_check( - persistence: ComparisonStore, - simulation: PlannedSimulationExecutionInput, -) -> Callable[[], bool]: - """Return a check of whether the coordinator has stopped this simulation.""" - - def cancellation_requested() -> bool: - try: - latest = persistence.get_simulation(simulation.simulation_execution_id) - except Exception as error: # noqa: BLE001 # a failed status read is logged and treated as not cancelled, so the calculation continues - logger.warning( - "Stage 12 cancellation check failed for %s (%s)", - simulation.simulation_execution_id, - type(error).__name__, - ) - return False - return latest.status is not ComparisonRunLifecycleStatus.RUNNING - - return cancellation_requested + No Stage 12 row is read or written. The simulation is always calculated: + no record can carry a cancellation, so the coordinator stops this call + through Modal instead. Its input and output are stored under keys naming + their digests, so a call Modal restarts after pre-empting it can always + store its own. + A failure is reported once, recorded as the operation's cause, and raised + as ``ReportExecutionError`` with the redacted detail a Stage 12 row would + record. + """ + runtime = run.dependencies.runtime + try: + descriptor = run.calculate_and_store(_never_cancelled) + except Exception as error: # noqa: BLE001 # every calculation failure reaches the caller as a typed error with its redacted detail + detail = simulation_failure_detail( + error, + run.simulation, + runtime=runtime, + progress=run.progress, + ) + record_failure_cause(runtime, error, detail.error_summary) + raise report_execution_error(run.simulation.evaluation_id, detail) from None + return descriptor.model_dump(mode="json") def _calculate( @@ -644,76 +673,3 @@ def _calculate( if isinstance(calculated, SimulationCalculation): return calculated return SimulationCalculation(frames=calculated) - - -def _record_success( - persistence: ComparisonStore, - running: ComparisonSimulationRecord, - descriptor: SimulationArtifactDescriptor, -) -> None: - """Record the stored artifact on the running simulation. - - Raises ``Stage12CancellationError`` when the coordinator stopped the - simulation first. - """ - completed = datetime.now(UTC) - _, replaced = persistence.replace_simulation_if_status( - running.model_copy( - update={ - "status": ComparisonRunLifecycleStatus.SUCCEEDED, - "output_uri": descriptor.artifact.uri, - "output_sha256": descriptor.artifact.content_sha256, - "output_schema_version": descriptor.output_schema_version, - "row_identity_columns": descriptor.row_identity.identifier_columns, - "row_count": descriptor.row_identity.row_count, - "row_identity_sha256": descriptor.row_identity.identity_sha256, - "updated_at": completed, - "completed_at": completed, - } - ), - expected_status=ComparisonRunLifecycleStatus.RUNNING, - ) - _require_completion_recorded(replaced=replaced) - - -def _record_failure( - error: Exception, - simulation: PlannedSimulationExecutionInput, - *, - persistence: ComparisonStore, - runtime: ObservabilityRuntime | None, - progress: StageProgress, -) -> None: - """Report a calculation failure once, then record it if still running.""" - if isinstance(error, Stage12ExecutionError): - # A segmented calculation reports its segment failure where it - # occurs, before raising this error. - detail = error.detail - else: - detail = report_failure( - error, - FailureSite( - scope="simulation_execution", - default_code="simulation_execution_failed", - attributes={ - "simulation_role": simulation.role.value, - "evaluation_id": str(simulation.evaluation_id), - "simulation_execution_id": str(simulation.simulation_execution_id), - }, - ), - runtime=runtime, - progress=progress, - ) - record_failure_cause(runtime, error, detail.error_summary) - failed_at = datetime.now(UTC) - persist_failure_record( - partial( - _mark_running_simulation_failed, - persistence, - simulation.simulation_execution_id, - detail=detail, - failed_at=failed_at, - ), - runtime=runtime, - failure_scope="simulation_execution", - ) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation_records.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation_records.py new file mode 100644 index 000000000..db7441ddb --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation_records.py @@ -0,0 +1,276 @@ +"""A Stage 12 simulation's record: its claim, cancellation, success and failure. + +The single-simulation worker (``simulation``) claims a planned simulation's +row before calculating it, checks the row for a cancellation while it runs, +and records the stored artifact or the redacted failure on it. +``descriptor_from_record`` rebuilds the artifact descriptor of a simulation +that already succeeded. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from functools import partial +from typing import TYPE_CHECKING + +from policyengine_simulation_contract.stage12_execution import ( + ArtifactMediaType, + ArtifactReference, + ComparisonRunLifecycleStatus, + ComparisonSimulationRecord, + PlannedSimulationExecutionInput, + RowIdentity, + SimulationArtifactDescriptor, + stage12_output_plan_sha256, +) + +from .failure_causes import StageProgress, record_failure_cause +from .failure_recording import FailureSite, persist_failure_record, report_failure +from .failures import ( + Stage12CancellationError, + Stage12ExecutionError, + Stage12FailureDetail, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from policyengine_observability import ObservabilityRuntime + + from .dependencies import ComparisonStore + +# Moved out of simulation.py; a failed cancellation check still logs under +# its name. +logger = logging.getLogger( + "policyengine_simulation_executor.stage12_runtime.simulation" +) + + +def descriptor_from_record( + child: ComparisonSimulationRecord, + simulation: PlannedSimulationExecutionInput, +) -> SimulationArtifactDescriptor: + """Rebuild the artifact descriptor of a child that already succeeded. + + ``size_bytes`` is 0 because the record does not store it; raises + ``ValueError`` if the record lacks any output metadata. + """ + output_uri = child.output_uri + output_sha256 = child.output_sha256 + output_schema_version = child.output_schema_version + row_identity_columns = child.row_identity_columns + row_count = child.row_count + row_identity_sha256 = child.row_identity_sha256 + if ( + output_uri is None + or output_sha256 is None + or output_schema_version != 1 + or row_identity_columns is None + or row_count is None + or row_identity_sha256 is None + ): + msg = "successful child record has incomplete output metadata" + raise ValueError(msg) + + return SimulationArtifactDescriptor( + evaluation_id=simulation.evaluation_id, + simulation_execution_id=simulation.simulation_execution_id, + role=simulation.role, + artifact=ArtifactReference( + uri=output_uri, + media_type=ArtifactMediaType.PARQUET, + content_sha256=output_sha256, + size_bytes=0, + ), + output_schema_version=1, + output_plan_sha256=stage12_output_plan_sha256(simulation.output_plan), + row_identity=RowIdentity( + identifier_columns=row_identity_columns, + row_count=row_count, + identity_sha256=row_identity_sha256, + ), + bundle=simulation.bundle, + ) + + +def _require_completion_recorded(*, replaced: bool) -> None: + """Raise ``Stage12CancellationError`` when the completion write replaced nothing. + + The coordinator records a cancelled simulation as failed while it is still + running, so a write that finds the record no longer running means this + simulation was cancelled. + """ + if not replaced: + msg = "Stage 12 simulation was cancelled" + raise Stage12CancellationError(msg) + + +def _mark_running_simulation_failed( + persistence: ComparisonStore, + simulation_execution_id: UUID, + *, + detail: Stage12FailureDetail, + failed_at: datetime, +) -> None: + """Record a failure only while the simulation is still running.""" + latest = persistence.get_simulation(simulation_execution_id) + if latest.status is not ComparisonRunLifecycleStatus.RUNNING: + return + persistence.replace_simulation_if_status( + latest.model_copy( + update={ + "status": ComparisonRunLifecycleStatus.FAILED, + "error_code": detail.error_code, + "error_summary": detail.error_summary, + "updated_at": failed_at, + "completed_at": failed_at, + } + ), + expected_status=ComparisonRunLifecycleStatus.RUNNING, + ) + + +def claim_simulation( + persistence: ComparisonStore, + child: ComparisonSimulationRecord, +) -> ComparisonSimulationRecord: + """Mark an active simulation running and return its record, or refuse it. + + The claim is a conditional write, so a simulation whose record changed + meanwhile, for example because the coordinator cancelled it, is not run. + """ + if child.status not in { + ComparisonRunLifecycleStatus.PENDING, + ComparisonRunLifecycleStatus.RUNNING, + }: + msg = "Stage 12 simulation is no longer active" + raise RuntimeError(msg) + started = datetime.now(UTC) + running = child.model_copy( + update={ + "status": ComparisonRunLifecycleStatus.RUNNING, + "started_at": child.started_at or started, + "updated_at": started, + "error_code": None, + "error_summary": None, + } + ) + running, claimed = persistence.replace_simulation_if_status( + running, + expected_status=child.status, + ) + if not claimed: + msg = "Stage 12 simulation was cancelled" + raise RuntimeError(msg) + return running + + +def cancellation_check( + persistence: ComparisonStore, + simulation: PlannedSimulationExecutionInput, +) -> Callable[[], bool]: + """Return a check of whether the coordinator has stopped this simulation.""" + + def cancellation_requested() -> bool: + try: + latest = persistence.get_simulation(simulation.simulation_execution_id) + except Exception as error: # noqa: BLE001 # a failed status read is logged and treated as not cancelled, so the calculation continues + logger.warning( + "Stage 12 cancellation check failed for %s (%s)", + simulation.simulation_execution_id, + type(error).__name__, + ) + return False + return latest.status is not ComparisonRunLifecycleStatus.RUNNING + + return cancellation_requested + + +def record_success( + persistence: ComparisonStore, + running: ComparisonSimulationRecord, + descriptor: SimulationArtifactDescriptor, +) -> None: + """Record the stored artifact on the running simulation. + + Raises ``Stage12CancellationError`` when the coordinator stopped the + simulation first. + """ + completed = datetime.now(UTC) + _, replaced = persistence.replace_simulation_if_status( + running.model_copy( + update={ + "status": ComparisonRunLifecycleStatus.SUCCEEDED, + "output_uri": descriptor.artifact.uri, + "output_sha256": descriptor.artifact.content_sha256, + "output_schema_version": descriptor.output_schema_version, + "row_identity_columns": descriptor.row_identity.identifier_columns, + "row_count": descriptor.row_identity.row_count, + "row_identity_sha256": descriptor.row_identity.identity_sha256, + "updated_at": completed, + "completed_at": completed, + } + ), + expected_status=ComparisonRunLifecycleStatus.RUNNING, + ) + _require_completion_recorded(replaced=replaced) + + +def simulation_failure_detail( + error: Exception, + simulation: PlannedSimulationExecutionInput, + *, + runtime: ObservabilityRuntime | None, + progress: StageProgress, +) -> Stage12FailureDetail: + """Report a calculation failure once and return its redacted detail.""" + if isinstance(error, Stage12ExecutionError): + # A segmented calculation reports its segment failure where it + # occurs, before raising this error. + return error.detail + return report_failure( + error, + FailureSite( + scope="simulation_execution", + default_code="simulation_execution_failed", + attributes={ + "simulation_role": simulation.role.value, + "evaluation_id": str(simulation.evaluation_id), + "simulation_execution_id": str(simulation.simulation_execution_id), + }, + ), + runtime=runtime, + progress=progress, + ) + + +def record_failure( + error: Exception, + simulation: PlannedSimulationExecutionInput, + *, + persistence: ComparisonStore, + runtime: ObservabilityRuntime | None, + progress: StageProgress, +) -> None: + """Report a calculation failure once, then record it if still running.""" + detail = simulation_failure_detail( + error, + simulation, + runtime=runtime, + progress=progress, + ) + record_failure_cause(runtime, error, detail.error_summary) + failed_at = datetime.now(UTC) + persist_failure_record( + partial( + _mark_running_simulation_failed, + persistence, + simulation.simulation_execution_id, + detail=detail, + failed_at=failed_at, + ), + runtime=runtime, + failure_scope="simulation_execution", + ) diff --git a/projects/policyengine-simulation-executor/tests/stage12_child_call_support.py b/projects/policyengine-simulation-executor/tests/stage12_child_call_support.py index 097888f11..8a172084b 100644 --- a/projects/policyengine-simulation-executor/tests/stage12_child_call_support.py +++ b/projects/policyengine-simulation-executor/tests/stage12_child_call_support.py @@ -296,6 +296,28 @@ def _call(self, role): return call +class ReformDispatchFailureInvoker(ScriptedChildInvoker): + def spawn(self, request): + role = PlannedSimulationExecutionInput.model_validate(request.simulation).role + if role is SimulationRole.REFORM: + self.events.append("spawn:reform") + msg = "dispatch contains sensitive details" + raise ConnectionError(msg) + return super().spawn(request) + + +class ContextRecordingInvoker(ConcurrentInvoker): + """Record the dispatch context each child simulation receives.""" + + def __init__(self, artifacts, **options): + super().__init__(artifacts, **options) + self.child_contexts = [] + + def spawn(self, request): + self.child_contexts.append(request.context) + return super().spawn(request) + + class PlaceholderWriteFailingStore(FakeStore): def __init__(self, failing_simulation_id): super().__init__() diff --git a/projects/policyengine-simulation-executor/tests/stage12_fake_stores.py b/projects/policyengine-simulation-executor/tests/stage12_fake_stores.py index dd56cdb8d..a2b2adb20 100644 --- a/projects/policyengine-simulation-executor/tests/stage12_fake_stores.py +++ b/projects/policyengine-simulation-executor/tests/stage12_fake_stores.py @@ -118,6 +118,8 @@ def __init__(self): def write_input(self, *, prefix, simulation): self.input_writes.append((prefix, simulation.role)) + write_input_named_by_digest = write_input + def add_simulation(self, simulation, frames=None): payload, row_identity = serialize_simulation_frames( frames or simulation_frames() @@ -174,6 +176,10 @@ def write_simulation( update={"calculation_provenance": calculation_provenance} ) + # A returned simulation names its input and output by their digests; this + # fake keeps one of each per role either way. + write_simulation_named_by_digest = write_simulation + def read(self, uri): if uri in self.payloads: return self.payloads[uri] 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..9ce5cd33d --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_call_report_function.py @@ -0,0 +1,84 @@ +"""The v2 app's report_call_ended function, which tells sim that a call ended.""" + +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( + 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) + + +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:] 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 == [] diff --git a/projects/policyengine-simulation-executor/tests/test_economy_comparison_call.py b/projects/policyengine-simulation-executor/tests/test_economy_comparison_call.py new file mode 100644 index 000000000..b8bfe3d71 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_economy_comparison_call.py @@ -0,0 +1,272 @@ +"""One v2 economy report run's comparison call: read both outputs, compare them.""" + +from __future__ import annotations + +from hashlib import sha256 +from uuid import UUID + +import pandas as pd +import pytest +from fixtures.fake_object_store import InMemoryObjectStore +from fixtures.macro_output import CURRENT_SINGLE_YEAR_MACRO_RESULT +from policyengine_simulation_contract.economy_runs import ( + EconomyComparisonCall, + EconomyComparisonSide, +) +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.report_execution import ReportExecutionError +from policyengine_simulation_contract.stage12_execution import ( + AggregateReportArtifactPayload, + EntityOutputPlan, + ReportAggregate, + ReportOutputRequirements, + SimulationExecutionInput, + SimulationRole, + Stage12OutputPlan, +) +from policyengine_simulation_executor.economy_runs import comparison_call +from policyengine_simulation_executor.economy_runs.comparison_call import ( + EconomyComparisonDependencies, + compare_economy_simulations, +) +from policyengine_simulation_executor.stage12_artifacts import ( + Stage12ArtifactStore, + serialize_simulation_frames, +) +from test_economy_simulation_call import STATES, _installed_bundle + +REPORT_RUN_ID = UUID("00000000-0000-0000-0000-0000000000c1") +BASELINE_RUN_ID = UUID("00000000-0000-0000-0000-0000000000b1") +REFORM_RUN_ID = UUID("00000000-0000-0000-0000-0000000000b2") +REFORM_POLICY = {"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01": 3000}} +LABOR_SUPPLY_POLICY = { + "gov.simulation.labor_supply_responses.elasticities.income": {"2026-01-01": 0.1} +} + + +def _frames(value: float, *, labor_supply: bool = False) -> dict[str, pd.DataFrame]: + household = {"household_id": [1, 2], "household_net_income": [value, value]} + if labor_supply: + household["income_elasticity_lsr"] = [0.0, 0.0] + return {"household": pd.DataFrame(household)} + + +def _plan(_simulation: SimulationExecutionInput, *, labor_supply_response: bool): + columns = ["household_id", "household_net_income"] + if labor_supply_response: + columns.append("income_elasticity_lsr") + return Stage12OutputPlan( + country="us", + requirements=ReportOutputRequirements( + aggregates=tuple(ReportAggregate), + include_cliff_impacts=False, + labor_supply_response_active=labor_supply_response, + ), + entities=( + EntityOutputPlan(entity="household", materialized_variables=tuple(columns)), + ), + ) + + +class _Objects: + def __init__(self) -> None: + self.objects = InMemoryObjectStore() + self.artifacts = Stage12ArtifactStore("private", store=self.objects) + + def store(self, name: str, frames: dict[str, pd.DataFrame]) -> tuple[str, str]: + payload, _identity = serialize_simulation_frames(frames) + key = f"stage-12-runs/staging/2026/10/{name}.parquet" + self.objects.upload_bytes( + key, payload, content_type="application/vnd.apache.parquet" + ) + return f"gs://private/{key}", sha256(payload).hexdigest() + + +def _side( + objects: _Objects, + run_id: UUID, + frames: dict[str, pd.DataFrame], + policy: dict, +) -> EconomyComparisonSide: + uri, digest = objects.store(str(run_id), frames) + return EconomyComparisonSide( + simulation_run_id=run_id, policy=policy, output_uri=uri, output_sha256=digest + ) + + +def _call( + baseline: EconomyComparisonSide, reform: EconomyComparisonSide, **changes +) -> EconomyComparisonCall: + values = { + "report_run_id": REPORT_RUN_ID, + "country": "us", + "year": 2026, + "policyengine_version": "6.7.5", + "region_code": "state/ca", + "dataset_name": STATES.identity, + "baseline": baseline, + "reform": reform, + } + values.update(changes) + return EconomyComparisonCall.model_validate(values) + + +@pytest.fixture +def installed(monkeypatch): + monkeypatch.setattr(comparison_call, "load_stage12_bundle", _installed_bundle) + + +class _Aggregator: + def __init__(self) -> None: + self.calls: list[dict] = [] + + def __call__(self, *, report, baseline, reform, uk_local_authority_metadata): + self.calls.append( + { + "report": report, + "baseline": baseline, + "reform": reform, + "uk_local_authority_metadata": uk_local_authority_metadata, + } + ) + return AggregateReportArtifactPayload( + evaluation_id=report.evaluation_id, + requested_aggregates=report.requested_aggregates, + bundle=report.baseline.bundle, + result=CURRENT_SINGLE_YEAR_MACRO_RESULT, + ).model_dump(mode="json") + + +def _compare(objects: _Objects, call: EconomyComparisonCall, aggregator=None): + return compare_economy_simulations( + call.model_dump(mode="json"), + dependencies=EconomyComparisonDependencies( + artifacts=objects.artifacts, + aggregator=aggregator or _Aggregator(), + output_planner=_plan, + ), + ) + + +def test_a_comparison_aggregates_both_stored_outputs_into_the_result( + installed, +) -> None: + objects = _Objects() + baseline = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + reform = _side(objects, REFORM_RUN_ID, _frames(110.0), REFORM_POLICY) + aggregator = _Aggregator() + + result = _compare(objects, _call(baseline, reform), aggregator) + + assert SingleYearMacroOutput.model_validate(result) == ( + SingleYearMacroOutput.model_validate(CURRENT_SINGLE_YEAR_MACRO_RESULT) + ) + used = aggregator.calls[0] + report = used["report"] + assert report.evaluation_id == REPORT_RUN_ID + assert report.baseline.role is SimulationRole.BASELINE + assert report.reform.role is SimulationRole.REFORM + assert report.baseline.policy == {} + assert report.reform.policy == REFORM_POLICY + assert report.baseline.geography.region == "state/ca" + assert report.baseline.bundle.dataset.identity == STATES.identity + assert used["baseline"].frames["household"]["household_net_income"].tolist() == [ + 100.0, + 100.0, + ] + assert used["reform"].frames["household"]["household_net_income"].tolist() == [ + 110.0, + 110.0, + ] + assert used["uk_local_authority_metadata"] is None + + +def test_a_run_compared_with_itself_reads_its_output_once_for_each_side( + installed, +) -> None: + objects = _Objects() + side = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + + result = _compare(objects, _call(side, side)) + + SingleYearMacroOutput.model_validate(result) + + +def test_a_stored_output_that_differs_from_its_digest_is_refused(installed) -> None: + objects = _Objects() + baseline = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + reform = _side(objects, REFORM_RUN_ID, _frames(110.0), REFORM_POLICY) + tampered = reform.model_copy(update={"output_sha256": "0" * 64}) + + with pytest.raises(ReportExecutionError) as raised: + _compare(objects, _call(baseline, tampered)) + + assert raised.value.failure.evaluation_id == REPORT_RUN_ID + + +def test_outputs_of_different_households_are_not_compared(installed) -> None: + objects = _Objects() + baseline = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + other = { + "household": pd.DataFrame( + {"household_id": [1, 3], "household_net_income": [1.0, 1.0]} + ) + } + reform = _side(objects, REFORM_RUN_ID, other, REFORM_POLICY) + aggregator = _Aggregator() + + with pytest.raises(ReportExecutionError): + _compare(objects, _call(baseline, reform), aggregator) + + assert aggregator.calls == [] + + +def test_a_labour_supply_comparison_needs_both_outputs_columns(installed) -> None: + objects = _Objects() + baseline = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + reform = _side( + objects, REFORM_RUN_ID, _frames(110.0, labor_supply=True), LABOR_SUPPLY_POLICY + ) + aggregator = _Aggregator() + + with pytest.raises(ReportExecutionError): + _compare(objects, _call(baseline, reform), aggregator) + + assert aggregator.calls == [] + + +def test_a_labour_supply_comparison_with_both_columns_is_compared(installed) -> None: + objects = _Objects() + baseline = _side(objects, BASELINE_RUN_ID, _frames(100.0, labor_supply=True), {}) + reform = _side( + objects, REFORM_RUN_ID, _frames(110.0, labor_supply=True), LABOR_SUPPLY_POLICY + ) + + SingleYearMacroOutput.model_validate(_compare(objects, _call(baseline, reform))) + + +def test_a_comparison_for_another_policyengine_version_is_refused(installed) -> None: + objects = _Objects() + side = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + + with pytest.raises(ValueError, match=r"installs PolicyEngine\.py 6\.7\.5"): + _compare(objects, _call(side, side, policyengine_version="6.8.0")) + + +def test_an_aggregate_that_describes_another_report_is_refused(installed) -> None: + objects = _Objects() + side = _side(objects, BASELINE_RUN_ID, _frames(100.0), {}) + + def describe_another_report( + *, report, baseline, reform, uk_local_authority_metadata + ): + del baseline, reform, uk_local_authority_metadata + return AggregateReportArtifactPayload( + evaluation_id=BASELINE_RUN_ID, + requested_aggregates=report.requested_aggregates, + bundle=report.baseline.bundle, + result=CURRENT_SINGLE_YEAR_MACRO_RESULT, + ).model_dump(mode="json") + + with pytest.raises(ReportExecutionError): + _compare(objects, _call(side, side), describe_another_report) diff --git a/projects/policyengine-simulation-executor/tests/test_economy_output_planning.py b/projects/policyengine-simulation-executor/tests/test_economy_output_planning.py new file mode 100644 index 000000000..fbdacbfe2 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_economy_output_planning.py @@ -0,0 +1,73 @@ +"""A v2 economy simulation run's output columns serve any report it is in.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from policyengine_simulation_contract.stage12_execution import ReportAggregate +from policyengine_simulation_executor.stage12_runtime.output_planning import ( + resolve_report_output_plan, + resolve_simulation_output_plan, +) +from test_stage12_output_planning import _report + +if TYPE_CHECKING: + from policyengine_simulation_contract.stage12_execution import Stage12OutputPlan + +LABOR_SUPPLY_REFORM = {"gov.simulation.labor_supply_responses.elasticities.income": 0.1} +UNRELATED_REFORM = {"gov.irs.credits.ctc.amount.base[0].amount": 3_000} + + +def _columns(plan: Stage12OutputPlan) -> dict[str, tuple[tuple[str, ...], ...]]: + return { + entity.entity: ( + entity.materialized_variables, + entity.additional_variables, + entity.dataset_variables, + ) + for entity in plan.entities + } + + +@pytest.mark.parametrize("country", ["us", "uk"]) +def test_a_simulation_plans_the_columns_a_report_without_labour_supply_plans( + country, +) -> None: + report = _report(country) + + plan = resolve_simulation_output_plan(report.baseline, labor_supply_response=False) + + assert _columns(plan) == _columns(resolve_report_output_plan(report)) + assert plan.requirements.aggregates == tuple(ReportAggregate) + assert plan.requirements.include_cliff_impacts is False + assert plan.requirements.labor_supply_response_active is False + + +def test_a_current_law_simulation_plans_the_labour_supply_columns_on_request() -> None: + report = _report("us", reform=LABOR_SUPPLY_REFORM) + + plan = resolve_simulation_output_plan(report.baseline, labor_supply_response=True) + + assert _columns(plan) == _columns(resolve_report_output_plan(report)) + assert plan.requirements.labor_supply_response_active is True + + +def test_a_reform_plans_the_same_columns_as_current_law() -> None: + report = _report("us", reform=UNRELATED_REFORM) + + baseline = resolve_simulation_output_plan( + report.baseline, labor_supply_response=False + ) + reform = resolve_simulation_output_plan(report.reform, labor_supply_response=False) + + assert baseline == reform + + +@pytest.mark.parametrize("region", ["constituency/E14001063", "country/wales"]) +def test_a_uk_area_simulation_plans_the_area_codes_its_report_reads(region) -> None: + report = _report("uk", region=region) + + plan = resolve_simulation_output_plan(report.baseline, labor_supply_response=False) + + assert _columns(plan) == _columns(resolve_report_output_plan(report)) diff --git a/projects/policyengine-simulation-executor/tests/test_economy_run_functions.py b/projects/policyengine-simulation-executor/tests/test_economy_run_functions.py new file mode 100644 index 000000000..d0ad33762 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_economy_run_functions.py @@ -0,0 +1,186 @@ +"""The v2 app's economy functions: each reports that its call ended.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest +from policyengine_simulation_contract.economy_runs import ( + ECONOMY_COMPARISON_FUNCTION, + ECONOMY_SIMULATION_FUNCTIONS, +) +from policyengine_simulation_executor.economy_runs import ( + comparison_call, + simulation_call, +) +from test_stage12_modal_app import RecordingRuntime, _load, _observability_context + +RUN_ID = "00000000-0000-0000-0000-0000000000a1" +REPORT_RUN_ID = "00000000-0000-0000-0000-0000000000c1" +CALL_ID = "fc-01JB3W7X9Z8Q2R5T6Y4U1I0O9P" + + +@pytest.fixture +def v2_app(monkeypatch): + module = _load(monkeypatch) + runtime = RecordingRuntime() + started: list[dict] = [] + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + monkeypatch.setattr( + module.modal, "current_function_call_id", lambda: CALL_ID, raising=False + ) + monkeypatch.setattr(module, "start_call_report", started.append) + module.started_reports = started + module.recording_runtime = runtime + return module + + +def test_the_v2_app_declares_each_economy_function_sim_starts(monkeypatch) -> None: + module = _load(monkeypatch) + functions = dict(module.app.function_calls) + + us = functions[ECONOMY_SIMULATION_FUNCTIONS["us"]] + uk = functions[ECONOMY_SIMULATION_FUNCTIONS["uk"]] + comparison = functions[ECONOMY_COMPARISON_FUNCTION] + assert us["image"] is module.us_worker_image + assert uk["image"] is module.uk_worker_image + assert comparison["image"] is module.coordinator_image + # The time limits of the Stage 12 workers, which a restart does not extend. + assert us["timeout"] == functions["run_single_simulation_us"]["timeout"] + assert uk["timeout"] == functions["run_single_simulation_uk"]["timeout"] + for declaration in (us, uk, comparison): + assert declaration["retries"] == 0 + assert declaration["secrets"] == module.worker_secrets + + +def test_a_simulation_call_reports_its_end_with_its_run(v2_app, monkeypatch) -> None: + received = [] + + def calculate(payload, **kwargs): + received.append((payload, kwargs)) + return {"stored": True} + + monkeypatch.setattr(simulation_call, "run_economy_simulation", calculate) + + result = v2_app.run_economy_simulation_uk( + {"simulation_run_id": RUN_ID}, observability_context=_observability_context() + ) + + assert result == {"stored": True} + assert received[0][1]["required_country"] == "uk" + assert ( + received[0][1]["invocation"].simulation_callable + == (ECONOMY_SIMULATION_FUNCTIONS["uk"]) + ) + assert received[0][1]["invocation"].application_name == v2_app.APP_NAME + assert v2_app.started_reports == [ + { + "modal_call_id": CALL_ID, + "simulation_run_ids": [RUN_ID], + "report_run_id": None, + } + ] + assert v2_app.recording_runtime.shutdown_calls == 1 + + +def test_a_failed_simulation_call_reports_its_end_and_raises( + v2_app, monkeypatch +) -> None: + def fail(_payload, **_kwargs): + msg = "calculation failed" + raise LookupError(msg) + + monkeypatch.setattr(simulation_call, "run_economy_simulation", fail) + + with pytest.raises(LookupError, match="calculation failed"): + v2_app.run_economy_simulation_us({"simulation_run_id": RUN_ID, "year": "x"}) + + assert [report["simulation_run_ids"] for report in v2_app.started_reports] == [ + [RUN_ID] + ] + + +def test_a_preempted_simulation_call_reports_nothing_until_its_rerun_ends( + v2_app, monkeypatch +) -> None: + attempts = [] + + def preempted_once(_payload, **_kwargs): + attempts.append("attempt") + if len(attempts) == 1: + # Modal delivers pre-emption to a synchronous input this way. + raise KeyboardInterrupt + return {"stored": True} + + monkeypatch.setattr(simulation_call, "run_economy_simulation", preempted_once) + + with pytest.raises(KeyboardInterrupt): + v2_app.run_economy_simulation_us({"simulation_run_id": RUN_ID}) + assert v2_app.started_reports == [] + + # Modal runs the same input again under the same call ID. + assert v2_app.run_economy_simulation_us({"simulation_run_id": RUN_ID}) == { + "stored": True + } + assert [report["modal_call_id"] for report in v2_app.started_reports] == [CALL_ID] + + +def test_a_call_without_a_run_it_can_name_reports_nothing(v2_app, monkeypatch) -> None: + monkeypatch.setattr( + simulation_call, "run_economy_simulation", lambda *_args, **_kwargs: {} + ) + + v2_app.run_economy_simulation_us({"simulation_run_id": "not-a-run"}) + + assert v2_app.started_reports == [] + + +def test_a_us_simulation_call_segments_in_this_application(v2_app, monkeypatch) -> None: + from policyengine_simulation_executor import stage12_runtime + + segmented = [] + monkeypatch.setattr( + stage12_runtime, + "run_segmented_simulation", + lambda simulation, **kwargs: segmented.append(kwargs) or "merged", + ) + + def calculate(_payload, *, dependencies, **_kwargs): + calculator = dependencies.worker.segmented_calculator + return {"calculated": calculator("simulation", lambda: False)} + + monkeypatch.setattr(simulation_call, "run_economy_simulation", calculate) + + assert v2_app.run_economy_simulation_us({"simulation_run_id": RUN_ID}) == { + "calculated": "merged" + } + assert segmented[0]["app_name"] == v2_app.APP_NAME + assert segmented[0]["runtime"] is v2_app.recording_runtime + + +def test_a_comparison_call_reports_its_end_with_its_report_run( + v2_app, monkeypatch +) -> None: + received = [] + + def compare(payload, *, dependencies): + received.append(dependencies) + return {"budget": {}} + + monkeypatch.setattr(comparison_call, "compare_economy_simulations", compare) + + result = v2_app.compare_economy_simulations( + {"report_run_id": REPORT_RUN_ID}, + observability_context=_observability_context(), + ) + + assert result == {"budget": {}} + assert received[0].runtime is v2_app.recording_runtime + assert v2_app.started_reports == [ + { + "modal_call_id": CALL_ID, + "simulation_run_ids": None, + "report_run_id": REPORT_RUN_ID, + } + ] + assert UUID(v2_app.started_reports[0]["report_run_id"]) diff --git a/projects/policyengine-simulation-executor/tests/test_economy_simulation_call.py b/projects/policyengine-simulation-executor/tests/test_economy_simulation_call.py new file mode 100644 index 000000000..c45c0c92c --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_economy_simulation_call.py @@ -0,0 +1,279 @@ +"""One v2 economy simulation run's Modal call: calculate, store, describe.""" + +from __future__ import annotations + +import math +from datetime import UTC, datetime +from hashlib import sha256 +from uuid import UUID + +import pandas as pd +import pytest +from fixtures.fake_object_store import InMemoryObjectStore +from policyengine.outputs import labor_supply_response as py_labor_supply +from policyengine_simulation_contract.economy_runs import ( + LABOR_SUPPLY_RESPONSE_PARAMETER_PREFIXES, + CatalogPopulation, + CatalogRowFilter, + EconomySimulationCall, + EconomySimulationOutput, +) +from policyengine_simulation_contract.report_execution import ReportExecutionError +from policyengine_simulation_contract.stage12_bundle import ( + ResolvedStage12Bundle, + Stage12BundleManifest, + Stage12CountryBundle, + Stage12Dataset, +) +from policyengine_simulation_contract.stage12_execution import ( + EntityOutputPlan, + GeographySelection, + PlannedSimulationExecutionInput, + ReportAggregate, + ReportOutputRequirements, + SimulationExecutionInput, + Stage12OutputPlan, + stage12_output_plan_sha256, +) +from policyengine_simulation_executor.economy_runs import simulation_call +from policyengine_simulation_executor.economy_runs.simulation_call import ( + EconomyRunInvocation, + EconomySimulationDependencies, + run_economy_simulation, +) +from policyengine_simulation_executor.stage12_artifacts import Stage12ArtifactStore +from policyengine_simulation_executor.stage12_runtime import ( + SimulationWorkerDependencies, +) +from policyengine_simulation_executor.stage12_runtime.catalog_population import ( + CATALOG_POPULATION_OPTION, +) + +RUN_ID = UUID("00000000-0000-0000-0000-0000000000a1") +NOW = datetime(2026, 10, 27, 12, tzinfo=UTC) +STATES = Stage12Dataset( + identity="populace_us_2024_acs_local", + uri="hf://policyengine/data/populace_us_2024_acs_local.h5@states", + artifact_revision="states", + sha256="c" * 64, + repo_type="dataset", +) +NATIONAL = Stage12Dataset( + identity="populace_us_2024", + uri="hf://policyengine/data/populace_us_2024.h5@revision", + artifact_revision="revision", + sha256="a" * 64, + repo_type="dataset", +) +INVOCATION = EconomyRunInvocation( + application_name="policyengine-simulation-v2-6-7-5", + modal_environment="staging", + simulation_callable="run_economy_simulation_us", +) + + +def _installed_bundle() -> ResolvedStage12Bundle: + return ResolvedStage12Bundle( + bundle=Stage12BundleManifest( + policyengine_version="6.7.5", + policyengine_requirement="policyengine==6.7.5", + core_package_version="3.0.0", + core_package_requirement="policyengine-core==3.0.0", + countries=( + Stage12CountryBundle( + country="us", + country_package_name="policyengine-us", + country_package_version="1.764.6", + country_package_requirement="policyengine-us==1.764.6", + data_package_name="populace-data", + data_package_version="0.1.0", + data_release_version="revision", + data_artifact_revision="revision", + default_dataset=NATIONAL.identity, + default_dataset_uri=NATIONAL.uri, + datasets=(NATIONAL, STATES), + ), + ), + ), + bundle_manifest_sha256="b" * 64, + ) + + +def _call(**changes: object) -> EconomySimulationCall: + values: dict[str, object] = { + "simulation_run_id": RUN_ID, + "country": "us", + "year": 2026, + "policyengine_version": "6.7.5", + "policy": {"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01": 3000}}, + "population": CatalogPopulation( + region_code="state/ca", + dataset_name=STATES.identity, + row_filter=CatalogRowFilter(field="state_fips", value="6"), + state_code="CA", + ), + "artifact_environment": "staging", + } + values.update(changes) + return EconomySimulationCall.model_validate(values) + + +def _plan(_simulation: SimulationExecutionInput, *, labor_supply_response: bool): + return Stage12OutputPlan( + country="us", + requirements=ReportOutputRequirements( + aggregates=tuple(ReportAggregate), + include_cliff_impacts=False, + labor_supply_response_active=labor_supply_response, + ), + entities=( + EntityOutputPlan( + entity="household", + materialized_variables=("household_id", "household_net_income"), + ), + ), + ) + + +def _frames(value: float = 100.0) -> dict[str, pd.DataFrame]: + return { + "household": pd.DataFrame( + {"household_id": [1, 2], "household_net_income": [value, 2 * value]} + ) + } + + +@pytest.fixture +def installed(monkeypatch): + monkeypatch.setattr(simulation_call, "load_stage12_bundle", _installed_bundle) + + +def _run(call: EconomySimulationCall, *, objects=None, calculator=None, **seams): + objects = objects if objects is not None else InMemoryObjectStore() + calculated: list[PlannedSimulationExecutionInput] = [] + + def record_and_calculate(planned): + calculated.append(planned) + return (calculator or (lambda _planned: _frames()))(planned) + + result = run_economy_simulation( + call.model_dump(mode="json"), + required_country=seams.pop("required_country", "us"), + invocation=INVOCATION, + dependencies=EconomySimulationDependencies( + worker=SimulationWorkerDependencies( + artifacts=Stage12ArtifactStore("private", store=objects), + calculator=record_and_calculate, + ), + output_planner=_plan, + clock=lambda: NOW, + ), + ) + return result, calculated, objects + + +def test_a_call_calculates_its_catalog_population_and_names_what_it_stored( + installed, +) -> None: + call = _call() + + result, calculated, objects = _run(call) + + output = EconomySimulationOutput.model_validate(result) + planned = calculated[0] + assert planned.geography == GeographySelection(country="us", region="state/ca") + assert planned.options == { + CATALOG_POPULATION_OPTION: call.population.model_dump(mode="json") + } + assert planned.policy == call.policy + assert planned.population.artifact.uri == STATES.uri + assert output.simulation_run_id == RUN_ID + assert output.bundle.dataset.identity == STATES.identity + assert output.output_plan_sha256 == stage12_output_plan_sha256( + _plan(planned, labor_supply_response=False) + ) + prefix = f"stage-12-runs/staging/2026/10/{RUN_ID}" + for stored in (output.input, output.output): + assert stored.uri.startswith(f"gs://private/{prefix}/") + key = stored.uri.removeprefix("gs://private/") + assert sha256(objects.objects[key]).hexdigest() == stored.content_sha256 + assert len(objects.objects[key]) == stored.size_bytes + + +def test_a_call_asks_for_labour_supply_columns_when_its_report_needs_them( + installed, +) -> None: + result, calculated, _objects = _run(_call(labor_supply_response=True)) + + output = EconomySimulationOutput.model_validate(result) + assert calculated[0].output_plan.requirements.labor_supply_response_active + assert output.output_plan_sha256 == stage12_output_plan_sha256( + _plan(calculated[0], labor_supply_response=True) + ) + + +def test_a_restarted_call_stores_output_that_differs_in_its_last_bits( + installed, +) -> None: + objects = InMemoryObjectStore() + call = _call() + + first, _calculated, _objects = _run(call, objects=objects) + restarted, _calculated, _objects = _run( + call, + objects=objects, + calculator=lambda _planned: _frames(math.nextafter(100.0, math.inf)), + ) + + assert first["output"]["uri"] != restarted["output"]["uri"] + assert first["input"] == restarted["input"] + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"policyengine_version": "6.8.0"}, "installs PolicyEngine.py 6.7.5"), + ( + { + "population": CatalogPopulation( + region_code="state/ca", + dataset_name="populace_us_2023", + row_filter=CatalogRowFilter(field="state_fips", value="6"), + ) + }, + "no dataset 'populace_us_2023'", + ), + ], +) +def test_a_call_this_application_cannot_run_is_refused_before_anything_is_stored( + installed, changes, message +) -> None: + objects = InMemoryObjectStore() + + with pytest.raises(ValueError, match=message): + _run(_call(**changes), objects=objects) + + assert objects.objects == {} + + +def test_a_call_for_another_country_is_refused(installed) -> None: + with pytest.raises(ValueError, match="another country"): + _run(_call(), required_country="uk") + + +def test_a_failed_calculation_reaches_the_caller_as_a_typed_error(installed) -> None: + def fail(_planned): + msg = "the dataset has no state_fips column" + raise KeyError(msg) + + with pytest.raises(ReportExecutionError) as raised: + _run(_call(), calculator=fail) + + assert raised.value.failure.error_code + + +def test_the_contract_names_the_labour_supply_parameters_policyengine_checks() -> None: + assert LABOR_SUPPLY_RESPONSE_PARAMETER_PREFIXES == { + "us": py_labor_supply.US_LSR_PARAMETER_PREFIXES, + "uk": py_labor_supply.UK_LSR_PARAMETER_PREFIXES, + } diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_artifacts.py b/projects/policyengine-simulation-executor/tests/test_stage12_artifacts.py index 2a1d6aa06..b4f6082f5 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_artifacts.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_artifacts.py @@ -20,6 +20,7 @@ validate_spm_calculation_provenance, ) from policyengine_simulation_contract.stage12_execution import ( + SimulationRole, UKLocalAuthorityBoundaryVersion, UKLocalAuthorityMetadata, ) @@ -32,6 +33,7 @@ serialize_simulation_frames, ) from pydantic import ValidationError +from stage12_runtime_support import planned_simulation def _frames(): @@ -205,6 +207,59 @@ def test_immutable_retry_accepts_identical_bytes_and_rejects_other_bytes() -> No store._write_immutable("path", b"different", content_type="application/json") +def test_a_simulation_named_by_digest_gets_one_key_per_distinct_output() -> None: + store = Stage12ArtifactStore("private-stage12", store=InMemoryObjectStore()) + simulation = planned_simulation(SimulationRole.BASELINE) + other_frames = _frames() + other_frames["household"]["household_net_income"] = np.array( + [20, 11], dtype=np.float32 + ) + + first = store.write_simulation_named_by_digest( + prefix="run", simulation=simulation, frames=_frames() + ) + repeated = store.write_simulation_named_by_digest( + prefix="run", simulation=simulation, frames=_frames() + ) + other = store.write_simulation_named_by_digest( + prefix="run", simulation=simulation, frames=other_frames + ) + fixed_key = store.write_simulation( + prefix="run", simulation=simulation, frames=_frames() + ) + + assert first.artifact.uri == ( + "gs://private-stage12/run/simulations/" + f"baseline-{first.artifact.content_sha256}.parquet" + ) + assert repeated == first + assert other.artifact.uri != first.artifact.uri + # The comparison path keeps its fixed key. + assert fixed_key.artifact.uri == ( + "gs://private-stage12/run/simulations/baseline.parquet" + ) + + +def test_an_input_named_by_digest_gets_one_key_per_distinct_input() -> None: + objects = InMemoryObjectStore() + store = Stage12ArtifactStore("private-stage12", store=objects) + simulation = planned_simulation(SimulationRole.BASELINE) + replanned = simulation.model_copy( + update={"policy": {"reform": {"2026-01-01.2026-12-31": 1}}} + ) + + first = store.write_input_named_by_digest(prefix="run", simulation=simulation) + repeated = store.write_input_named_by_digest(prefix="run", simulation=simulation) + other = store.write_input_named_by_digest(prefix="run", simulation=replanned) + + assert first.uri == ( + f"gs://private-stage12/run/inputs/baseline-{first.content_sha256}.json" + ) + assert repeated == first + assert other.uri != first.uri + assert len(objects.objects) == 2 + + def test_aggregate_write_rejects_an_undeclared_payload_shape() -> None: store = Stage12ArtifactStore("private-stage12", store=InMemoryObjectStore()) diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_catalog_population.py b/projects/policyengine-simulation-executor/tests/test_stage12_catalog_population.py new file mode 100644 index 000000000..b848399e9 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_stage12_catalog_population.py @@ -0,0 +1,349 @@ +"""A v2 economy run scopes its dataset with its catalog population's filters.""" + +from __future__ import annotations + +from contextlib import nullcontext +from types import SimpleNamespace + +import pytest +from policyengine.core.scoping_strategy import RegionGroupStrategy, RowFilterStrategy +from policyengine_simulation_contract.economy_runs import ( + CatalogPopulation, + CatalogRowFilter, +) +from policyengine_simulation_contract.stage12_bundle import ( + ResolvedStage12Bundle, + Stage12BundleManifest, + Stage12CountryBundle, + Stage12Dataset, +) +from policyengine_simulation_contract.stage12_execution import ( + BundleProvenance, + DatasetArtifactMediaType, + DatasetArtifactReference, + DatasetPopulationInput, + DatasetProvenance, + EntityOutputPlan, + GeographySelection, + PlannedSimulationExecutionInput, + ReportAggregate, + ReportOutputRequirements, + SimulationRole, + Stage12OutputPlan, +) +from policyengine_simulation_executor import simulation_runtime +from policyengine_simulation_executor.stage12_cache import state_datasets +from policyengine_simulation_executor.stage12_runtime import fanout +from policyengine_simulation_executor.stage12_runtime import simulation as worker +from policyengine_simulation_executor.stage12_runtime.catalog_population import ( + CATALOG_POPULATION_OPTION, + catalog_region_groups, +) +from policyengine_simulation_executor.stage12_runtime.partition import US_REGION_GROUPS + +NATIONAL = Stage12Dataset( + identity="populace_us_2024", + uri="hf://policyengine/data/populace_us_2024.h5@revision", + artifact_revision="revision", + sha256="a" * 64, + repo_type="dataset", +) +STATES = Stage12Dataset( + identity="populace_us_2024_acs_local", + uri="hf://policyengine/data/populace_us_2024_acs_local.h5@states", + artifact_revision="states", + sha256="c" * 64, + repo_type="dataset", +) +STATE_FILTERS = { + code: CatalogRowFilter(field="state_fips", value=str(index + 1)) + for index, code in enumerate( + sorted({code for group in US_REGION_GROUPS for code in group}) + ) +} + + +class _StoppedAfterLoadingError(Exception): + pass + + +def _installed_bundle() -> ResolvedStage12Bundle: + return ResolvedStage12Bundle( + bundle=Stage12BundleManifest( + policyengine_version="6.7.5", + policyengine_requirement="policyengine==6.7.5", + core_package_version="3.0.0", + core_package_requirement="policyengine-core==3.0.0", + countries=( + Stage12CountryBundle( + country="us", + country_package_name="policyengine-us", + country_package_version="1.764.6", + country_package_requirement="policyengine-us==1.764.6", + data_package_name="populace-data", + data_package_version="0.1.0", + data_release_version="revision", + data_artifact_revision="revision", + default_dataset=NATIONAL.identity, + default_dataset_uri=NATIONAL.uri, + datasets=(NATIONAL, STATES), + ), + ), + ), + bundle_manifest_sha256="b" * 64, + ) + + +def _provenance(dataset: Stage12Dataset) -> BundleProvenance: + return BundleProvenance( + policyengine_version="6.7.5", + country_package_name="policyengine-us", + country_package_version="1.764.6", + dataset=DatasetProvenance( + identity=dataset.identity, + uri=dataset.uri, + artifact_revision=dataset.artifact_revision, + data_package_name="populace-data", + data_package_version="0.1.0", + ), + bundle_manifest_sha256="b" * 64, + ) + + +def _planned( + population: CatalogPopulation, + dataset: Stage12Dataset, + *, + region_group: list[str] | None = None, +) -> PlannedSimulationExecutionInput: + options: dict = {CATALOG_POPULATION_OPTION: population.model_dump(mode="json")} + if region_group is not None: + options["region_group"] = region_group + return PlannedSimulationExecutionInput( + evaluation_id="00000000-0000-0000-0000-000000000001", + simulation_execution_id="00000000-0000-0000-0000-000000000001", + role=SimulationRole.BASELINE, + policy={}, + population=DatasetPopulationInput( + artifact=DatasetArtifactReference( + uri=dataset.uri, + media_type=DatasetArtifactMediaType.HDF5, + content_sha256=dataset.sha256, + ) + ), + year=2026, + geography=GeographySelection(country="us", region=population.region_code), + options=options, + bundle=_provenance(dataset), + output_plan=Stage12OutputPlan( + country="us", + requirements=ReportOutputRequirements( + aggregates=tuple(ReportAggregate), + include_cliff_impacts=False, + labor_supply_response_active=False, + ), + entities=( + EntityOutputPlan( + entity="household", + materialized_variables=("household_id",), + ), + ), + ), + ) + + +def _state_population() -> CatalogPopulation: + return CatalogPopulation( + region_code="state/ca", + dataset_name=STATES.identity, + row_filter=CatalogRowFilter(field="state_fips", value="6"), + state_code="CA", + ) + + +def _national_population() -> CatalogPopulation: + return CatalogPopulation( + region_code="us", + dataset_name=NATIONAL.identity, + state_filters=STATE_FILTERS, + ) + + +def _calculate_until_built( + monkeypatch: pytest.MonkeyPatch, planned: PlannedSimulationExecutionInput +) -> SimpleNamespace: + """Run the calculation up to building the simulation; return what it used.""" + loads: list = [] + built: list = [] + + def refuse_registry(*_args, **_kwargs): + pytest.fail("the region registry was consulted") + + def load_dataset(_params, *, selection, country_module, data_folder=None): + loads.append({"selection": selection, "data_folder": data_folder}) + return SimpleNamespace(data=SimpleNamespace(entity_data={})) + + def stop_after_building(_params, *, resolved, policy, execution): + built.append(resolved) + raise _StoppedAfterLoadingError + + monkeypatch.setattr(worker, "load_stage12_bundle", _installed_bundle) + monkeypatch.setattr(simulation_runtime, "setup_gcp_credentials", nullcontext) + monkeypatch.setattr(simulation_runtime, "_country_module", lambda _: object()) + monkeypatch.setattr(simulation_runtime, "resolve_region", refuse_registry) + monkeypatch.setattr( + simulation_runtime, "_resolve_dataset_selection", refuse_registry + ) + monkeypatch.setattr(simulation_runtime, "_load_dataset", load_dataset) + monkeypatch.setattr( + state_datasets, "baked_state_data_folder", lambda **_kwargs: None + ) + monkeypatch.setattr( + worker, "detect_uk_run_dataset_metadata", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr(worker, "build_stage12_simulation", stop_after_building) + with pytest.raises(_StoppedAfterLoadingError): + worker.calculate_simulation_frames(planned) + return SimpleNamespace(loads=loads, resolved=built[0]) + + +def test_a_catalog_state_run_filters_on_the_catalog_value_without_the_registry( + monkeypatch, +) -> None: + used = _calculate_until_built(monkeypatch, _planned(_state_population(), STATES)) + + assert used.resolved.region.code == "state/ca" + assert used.resolved.region.state_code == "CA" + assert used.resolved.region.scoping_strategy == RowFilterStrategy( + variable_name="state_fips", variable_value=6 + ) + assert used.loads[0]["selection"].name == STATES.identity + assert used.loads[0]["selection"].is_default is False + + +def test_a_catalog_national_run_simulates_the_whole_named_dataset( + monkeypatch, +) -> None: + used = _calculate_until_built( + monkeypatch, _planned(_national_population(), NATIONAL) + ) + + assert used.resolved.region.scoping_strategy is None + assert used.loads[0]["selection"].name == NATIONAL.identity + assert used.loads[0]["selection"].is_default is True + + +def test_a_catalog_segment_is_scoped_to_its_states_catalog_filters( + monkeypatch, +) -> None: + used = _calculate_until_built( + monkeypatch, + _planned( + _national_population(), NATIONAL, region_group=["state/ny", "state/ca"] + ), + ) + + strategy = used.resolved.region.scoping_strategy + assert isinstance(strategy, RegionGroupStrategy) + assert strategy.members == [ + RowFilterStrategy( + variable_name="state_fips", + variable_value=STATE_FILTERS["state/ny"].household_value(), + ), + RowFilterStrategy( + variable_name="state_fips", + variable_value=STATE_FILTERS["state/ca"].household_value(), + ), + ] + + +def test_a_catalog_segment_with_a_state_the_catalog_lacks_is_refused( + monkeypatch, +) -> None: + population = _national_population().model_copy( + update={"state_filters": {"state/ca": STATE_FILTERS["state/ca"]}} + ) + + with pytest.raises(ValueError, match="no filter for state/ny"): + _calculate_until_built( + monkeypatch, + _planned(population, NATIONAL, region_group=["state/ny", "state/ca"]), + ) + + +def test_a_catalog_dataset_the_installed_release_lacks_is_refused( + monkeypatch, +) -> None: + population = _state_population().model_copy( + update={"dataset_name": "populace_us_2023"} + ) + + with pytest.raises(ValueError, match="no dataset 'populace_us_2023'"): + _calculate_until_built(monkeypatch, _planned(population, STATES)) + + +def test_the_installed_bundle_accepts_the_dataset_the_catalog_names( + monkeypatch, +) -> None: + monkeypatch.setattr(worker, "load_stage12_bundle", _installed_bundle) + national_on_states = _national_population().model_copy( + update={"dataset_name": STATES.identity} + ) + + worker._require_installed_bundle(_planned(national_on_states, STATES)) + + +def test_the_installed_bundle_refuses_provenance_of_another_dataset( + monkeypatch, +) -> None: + monkeypatch.setattr(worker, "load_stage12_bundle", _installed_bundle) + planned = _planned(_state_population(), STATES).model_copy( + update={"bundle": _provenance(NATIONAL)} + ) + + with pytest.raises(RuntimeError, match="differs from the installed bundle"): + worker._require_installed_bundle(planned) + + +def test_catalog_segments_cover_every_catalog_state() -> None: + extra = CatalogRowFilter(field="state_fips", value="72") + population = _national_population().model_copy( + update={"state_filters": {**STATE_FILTERS, "state/pr": extra}} + ) + + groups = catalog_region_groups(population) + + assert len(groups) == len(US_REGION_GROUPS) + assert groups[-1][-1] == "state/pr" + assert {code for group in groups for code in group} == set( + population.state_filters or {} + ) + + +def test_catalog_segments_refuse_a_partition_state_the_catalog_lacks() -> None: + filters = dict(STATE_FILTERS) + del filters["state/tx"] + population = _national_population().model_copy(update={"state_filters": filters}) + + with pytest.raises(ValueError, match="no filter for state/tx"): + catalog_region_groups(population) + + +def test_a_catalog_segmented_run_uses_the_catalog_states(monkeypatch) -> None: + runner = fanout.Stage12SegmentRunner( + _planned(_national_population(), NATIONAL), + app_name="policyengine-simulation-v2-6-7-5", + runtime=SimpleNamespace(), + seams=fanout.SegmentRunnerSeams( + modal_module=SimpleNamespace( + Function=SimpleNamespace(from_name=lambda *_args: object()) + ) + ), + ) + monkeypatch.setattr( + fanout, + "_country_module", + lambda _country: pytest.fail("the region registry was consulted"), + ) + + assert runner._groups() == catalog_region_groups(_national_population()) diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_child_state_errors.py b/projects/policyengine-simulation-executor/tests/test_stage12_child_state_errors.py index 244a15dac..26060f7c7 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_child_state_errors.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_child_state_errors.py @@ -8,7 +8,6 @@ import pytest from policyengine_simulation_contract.stage12_execution import ( ComparisonRunLifecycleStatus, - PlannedSimulationExecutionInput, SimulationRole, ) from policyengine_simulation_executor.stage12_runtime import ( @@ -24,6 +23,7 @@ from stage12_child_call_support import ( ChildCallScript, ConcurrentInvoker, + ReformDispatchFailureInvoker, RefusingDispatchInvoker, ScriptedChildCall, ScriptedChildInvoker, @@ -139,16 +139,6 @@ def test_failed_child_state_error_does_not_replace_the_child_failure( ) -class ReformDispatchFailureInvoker(ScriptedChildInvoker): - def spawn(self, request): - role = PlannedSimulationExecutionInput.model_validate(request.simulation).role - if role is SimulationRole.REFORM: - self.events.append("spawn:reform") - msg = "dispatch contains sensitive details" - raise ConnectionError(msg) - return super().spawn(request) - - @pytest.mark.parametrize("failing_operation", ["read", "write"]) def test_dispatch_failure_state_error_does_not_replace_the_child_failure( caplog, diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_coordinator.py b/projects/policyengine-simulation-executor/tests/test_stage12_coordinator.py index fda435620..899c692b8 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_coordinator.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_coordinator.py @@ -26,7 +26,7 @@ from policyengine_simulation_executor.stage12_runtime import ( coordinate_report as coordinate_report_impl, ) -from stage12_child_call_support import ConcurrentInvoker +from stage12_child_call_support import ConcurrentInvoker, ContextRecordingInvoker from stage12_fake_stores import FakeArtifacts, FakeStore from stage12_runtime_support import ( EVALUATION_ID, @@ -146,6 +146,45 @@ def test_duplicate_coordinator_submission_does_not_start_duplicate_children() -> assert duplicate_invoker.events == [] +def test_stage12_children_receive_the_context_without_a_delivery_field() -> None: + artifacts = FakeArtifacts() + invoker = ContextRecordingInvoker(artifacts) + + coordinate_report( + report_execution_input().model_dump(mode="json"), + invocation_context().model_dump(mode="json"), + parent_record().model_dump(mode="json"), + application_name=invocation_context().modal_application, + coordinator_invocation_id="coordinator-1", + store=FakeStore(), + artifacts=artifacts, + invoker=invoker, + aggregator=lambda **_: aggregate_payload(), + ) + + # The Stage 12 worker payload keeps exactly the fields it had before the + # delivery field existed. + fields_before_the_delivery_field = { + "contract_version", + "request_id", + "environment", + "modal_environment", + "worker_version", + "modal_application", + "simulation_callable", + "version_manifest_sha256", + "bundle_manifest_sha256", + "artifact_prefix", + "production_function_call_id", + "created_at", + "retention_expires_at", + } + assert [set(context) for context in invoker.child_contexts] == [ + fields_before_the_delivery_field, + fields_before_the_delivery_field, + ] + + def test_coordinator_measures_output_and_child_input_planning() -> None: store = FakeStore() artifacts = FakeArtifacts() 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..db04536e3 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -7,9 +7,14 @@ import tomllib from contextlib import contextmanager from pathlib import Path +from uuid import UUID import pytest from fixtures.fake_modal import install_fake_modal +from policyengine_simulation_contract.report_execution import ( + ReportExecutionError, + ReportExecutionFailure, +) from policyengine_simulation_executor.release_bundle import ( get_bundled_package_version, ) @@ -326,6 +331,10 @@ def test_v2_app_declares_validation_workers_and_non_http_coordinator( "run_single_simulation_us", "run_single_simulation_uk", "coordinate_report", + "report_call_ended", + "run_economy_simulation_us", + "run_economy_simulation_uk", + "compare_economy_simulations", } assert functions["run_single_simulation_us"]["image"] is module.us_worker_image assert functions["run_single_simulation_uk"]["image"] is module.uk_worker_image @@ -398,6 +407,192 @@ def fail(*_, **__): assert runtime.shutdown_calls == 1 +def _invoke_coordinator( + monkeypatch, module, context, *parent, returned_error=None, runtime=None +): + """Call the Modal coordinator with both runtime coordinators replaced. + + The returned-report coordinator raises ``returned_error`` when one is given. + """ + from policyengine_simulation_executor import stage12_runtime + + runtime = runtime or RecordingRuntime() + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + monkeypatch.setattr( + module.modal, + "current_function_call_id", + lambda: "call-1", + raising=False, + ) + coordinator_calls = [] + + def stage12_coordinator( + _payload, _context_payload, parent_payload, *, invocation, dependencies + ): + coordinator_calls.append( + { + "coordinator": "stage12", + "parent": parent_payload, + "invocation_id": invocation.invocation_id, + "runtime": dependencies.runtime, + } + ) + return {"delivered_by": "stage12"} + + def returning_coordinator(_payload, _context_payload, *, invocation, dependencies): + coordinator_calls.append( + { + "coordinator": "return_to_caller", + "invocation_id": invocation.invocation_id, + "runtime": dependencies.runtime, + } + ) + if returned_error is not None: + raise returned_error + return {"delivered_by": "return_to_caller"} + + monkeypatch.setattr(stage12_runtime, "coordinate_report", stage12_coordinator) + monkeypatch.setattr( + stage12_runtime, "coordinate_report_for_caller", returning_coordinator + ) + result = module.coordinate_report( + {}, + context, + *parent, + observability_context=_observability_context(), + ) + return result, coordinator_calls, runtime + + +def test_coordinator_runs_a_returned_report_without_a_parent(monkeypatch) -> None: + module = _load(monkeypatch) + + result, coordinator_calls, runtime = _invoke_coordinator( + monkeypatch, module, {"result_delivery": "return_to_caller"} + ) + + assert result == {"delivered_by": "return_to_caller"} + assert coordinator_calls == [ + { + "coordinator": "return_to_caller", + "invocation_id": "call-1", + "runtime": runtime, + } + ] + # Telemetry tells a returned report apart from a Stage 12 run. + assert runtime.operations[0]["attributes"] == { + "runner_name": "stage12", + "execution_mode": "authoritative", + "result_delivery": "return_to_caller", + } + assert runtime.shutdown_calls == 1 + + +def test_coordinator_raises_a_returned_reports_error_unchanged(monkeypatch) -> None: + module = _load(monkeypatch) + runtime = RecordingRuntime() + error = ReportExecutionError( + ReportExecutionFailure( + evaluation_id=UUID("00000000-0000-0000-0000-000000000001"), + error_code="simulation_execution_failed", + error_summary="ValueError in dataset_load (baseline): bad input", + ) + ) + + with pytest.raises(ReportExecutionError) as raised: + _invoke_coordinator( + monkeypatch, + module, + {"result_delivery": "return_to_caller"}, + returned_error=error, + runtime=runtime, + ) + + # Modal pickles exactly what the function raises for the caller. + assert raised.value is error + assert runtime.shutdown_calls == 1 + + +def test_coordinator_refuses_a_parent_record_for_a_returned_report( + monkeypatch, +) -> None: + module = _load(monkeypatch) + + with pytest.raises(ValueError, match="no Stage 12 parent record"): + _invoke_coordinator( + monkeypatch, + module, + {"result_delivery": "return_to_caller"}, + {"evaluation_id": "parent"}, + ) + + +def test_coordinator_runs_a_stage12_report_with_unchanged_attributes( + monkeypatch, +) -> None: + module = _load(monkeypatch) + + result, coordinator_calls, runtime = _invoke_coordinator( + monkeypatch, module, {}, {"evaluation_id": "parent"} + ) + + assert result == {"delivered_by": "stage12"} + assert coordinator_calls == [ + { + "coordinator": "stage12", + "parent": {"evaluation_id": "parent"}, + "invocation_id": "call-1", + "runtime": runtime, + } + ] + assert runtime.operations[0]["attributes"] == { + "runner_name": "stage12", + "execution_mode": "authoritative", + } + + +@pytest.mark.parametrize( + "function_name", ["run_single_simulation_us", "run_single_simulation_uk"] +) +@pytest.mark.parametrize( + ("context", "expected_attributes"), + [ + ({}, {"runner_name": "stage12", "simulation_role": "baseline"}), + ( + {"result_delivery": "return_to_caller"}, + { + "runner_name": "stage12", + "simulation_role": "baseline", + "result_delivery": "return_to_caller", + }, + ), + ], +) +def test_worker_operation_says_when_its_report_returns_to_the_caller( + monkeypatch, + function_name, + context, + expected_attributes, +) -> None: + from policyengine_simulation_executor import stage12_runtime + + module = _load(monkeypatch) + runtime = RecordingRuntime() + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + monkeypatch.setattr( + stage12_runtime, "run_single_simulation", lambda *_, **__: {"ok": True} + ) + + getattr(module, function_name)( + {"role": "baseline"}, + context, + observability_context=_observability_context(), + ) + + # Telemetry tells a returned report's workers apart from Stage 12 runs. + assert runtime.operations[0]["attributes"] == expected_attributes + + def test_external_package_values_are_assertions_only(monkeypatch) -> None: install_fake_modal(monkeypatch) monkeypatch.setenv("STAGE12_EXPECT_US_VERSION", "not-the-bundled-version") diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_returned_report.py b/projects/policyengine-simulation-executor/tests/test_stage12_returned_report.py new file mode 100644 index 000000000..6360ce964 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_stage12_returned_report.py @@ -0,0 +1,1004 @@ +"""Tests for reports and simulations whose result returns to their caller. + +With ``result_delivery`` set to ``return_to_caller``, the report coordinator +and its simulation workers read and write no Stage 12 rows. They return typed +values, and a failure raises ``ReportExecutionError`` with the redacted detail +a Stage 12 row would record. +""" + +from __future__ import annotations + +import logging +import math +from contextlib import contextmanager +from hashlib import sha256 +from uuid import uuid4 + +import pytest +from fixtures.fake_modal import InputCancellation +from fixtures.fake_object_store import InMemoryObjectStore +from fixtures.macro_output import CURRENT_SINGLE_YEAR_MACRO_RESULT +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from policyengine_simulation_contract.report_execution import ( + ReportExecutionError, + ReportExecutionFailure, + ReportExecutionProvenance, + ReportExecutionResult, + report_aggregate_sha256, +) +from policyengine_simulation_contract.spm import SPMInputError +from policyengine_simulation_contract.stage12_execution import ( + PlannedSimulationExecutionInput, + ReportAggregate, + ReportResultDelivery, + SimulationArtifactDescriptor, + SimulationRole, + Stage12InvocationContext, +) +from policyengine_simulation_executor.stage12_artifacts import Stage12ArtifactStore +from policyengine_simulation_executor.stage12_runtime import ( + CoordinatorDependencies, + CoordinatorInvocation, + SimulationCalculation, + SimulationWorkerDependencies, + Stage12ExecutionError, + Stage12FailureDetail, + coordinate_report_for_caller, + run_single_simulation, +) +from policyengine_simulation_executor.stage12_runtime import ( + coordination as coordination_module, +) +from policyengine_simulation_executor.stage12_runtime import ( + simulation as simulation_module, +) +from policyengine_simulation_observability.stages import ( + STAGE12_CANONICAL_REPORT_STAGES, + Stage, +) +from pydantic import ValidationError +from stage12_child_call_support import ( + ConcurrentInvoker, + ContextRecordingInvoker, + InjectedChildFaults, + ReformDispatchFailureInvoker, + ScriptedChildInvoker, + assert_log_identifies_child, + coordination_log_record, + report_with_segmentation, +) +from stage12_fake_stores import FakeArtifacts, FakeStore, simulation_frames +from stage12_runtime_support import ( + BASELINE_ID, + EVALUATION_ID, + REFORM_ID, + aggregate_payload, + bundle_provenance, + child_record, + coordinate_report, + invocation_context, + parent_record, + planned_simulation, + report_execution_input, + shared_output_plan, + summary_failure_cause, +) + + +class StageRecordingRuntime: + """Record the stages a run opens and the failure causes it attaches.""" + + def __init__(self) -> None: + self.spans: list[str] = [] + self.operations: list[str] = [] + self.recorded_exceptions: list[Exception] = [] + # Raise InputCancellation as the span with this name and these + # attributes closes, as Modal's cancellation can arrive at any point. + self.stop_on_close: tuple[str, dict] | None = None + + @contextmanager + def span(self, name, *_args, attributes=None, **_kwargs): + self.spans.append(name) + yield + if self.stop_on_close == (name, attributes): + msg = "Input was cancelled by user" + raise InputCancellation(msg) + + @contextmanager + def operation(self, name, *_args, **_kwargs): + self.operations.append(name) + yield + + def capture_context(self): + return {} + + def record_exception(self, error, *, handled, status_code=None): + assert handled + self.recorded_exceptions.append(error) + + +class NoDatabaseStore: + """A store that fails the test on any use: a returned report writes no rows.""" + + def __getattr__(self, name): + pytest.fail(f"a returned report used the Stage 12 store ({name})") + + +@pytest.fixture +def forbid_database(monkeypatch): + """Fail the test if a returned report builds a Stage 12 database store.""" + + def refuse_store(*_args, **_kwargs): + pytest.fail("a returned report built a Stage 12 database store") + + monkeypatch.setattr(simulation_module, "runtime_store", refuse_store) + monkeypatch.setattr(coordination_module, "runtime_store", refuse_store) + + +def _returning_context() -> Stage12InvocationContext: + return invocation_context().model_copy( + update={"result_delivery": ReportResultDelivery.RETURN_TO_CALLER} + ) + + +def _run_returned_simulation(simulation, **seams): + seams.setdefault("artifacts", FakeArtifacts()) + return run_single_simulation( + simulation.model_dump(mode="json"), + _returning_context().model_dump(mode="json"), + required_country="us", + dependencies=SimulationWorkerDependencies(**seams), + ) + + +def test_returned_simulation_stores_its_artifact_without_the_database( + forbid_database, +) -> None: + simulation = planned_simulation(SimulationRole.BASELINE) + artifacts = FakeArtifacts() + + result = _run_returned_simulation( + simulation, + artifacts=artifacts, + calculator=lambda _: simulation_frames(), + ) + + descriptor = SimulationArtifactDescriptor.model_validate(result) + assert descriptor.role is SimulationRole.BASELINE + assert descriptor.simulation_execution_id == BASELINE_ID + stored = artifacts.read(descriptor.artifact.uri) + assert sha256(stored).hexdigest() == descriptor.artifact.content_sha256 + assert artifacts.input_writes == [ + (invocation_context().artifact_prefix, SimulationRole.BASELINE) + ] + + +def test_returned_simulation_never_uses_a_store_it_was_given() -> None: + simulation = planned_simulation(SimulationRole.BASELINE) + + result = _run_returned_simulation( + simulation, + store=NoDatabaseStore(), + calculator=lambda _: simulation_frames(), + ) + + assert result["role"] == "baseline" + + +def test_returned_simulation_times_the_same_stages_as_a_stage12_simulation( + forbid_database, +) -> None: + runtime = StageRecordingRuntime() + + _run_returned_simulation( + planned_simulation(SimulationRole.BASELINE), + calculator=lambda _: simulation_frames(), + runtime=runtime, + ) + + assert runtime.spans == [ + "stage12_input_write", + "stage12_calculation", + "stage12_simulation_artifact_write", + ] + + +def test_returned_simulation_failure_raises_its_redacted_detail( + forbid_database, +) -> None: + simulation = planned_simulation(SimulationRole.BASELINE) + runtime = StageRecordingRuntime() + + def fail(_simulation): + msg = "could not reach postgresql://runtime:database-secret@db.example/postgres" + raise RuntimeError(msg) + + with pytest.raises(ReportExecutionError) as raised: + _run_returned_simulation(simulation, calculator=fail, runtime=runtime) + + failure = raised.value.failure + assert failure.evaluation_id == EVALUATION_ID + assert failure.error_code == "simulation_execution_failed" + assert summary_failure_cause(failure.error_summary) == ( + "RuntimeError in stage12_calculation (baseline): " + "could not reach postgresql://[REDACTED]@db.example/postgres" + ) + assert str(raised.value) == "Report execution failed" + # The original error, with its unredacted message, stays in telemetry. + assert raised.value.__cause__ is None + assert raised.value.__suppress_context__ is True + [recorded_cause] = runtime.recorded_exceptions + assert str(recorded_cause) == failure.error_summary + + +def test_returned_simulation_keeps_a_typed_failure(forbid_database) -> None: + def fail(_simulation): + msg = "SPM_YEAR_UNAVAILABLE" + raise SPMInputError(msg, "No SPM values for 2026") + + with pytest.raises(ReportExecutionError) as raised: + _run_returned_simulation( + planned_simulation(SimulationRole.BASELINE), calculator=fail + ) + + assert raised.value.failure.error_code == "SPM_YEAR_UNAVAILABLE" + assert raised.value.failure.error_summary == "No SPM values for 2026" + + +def test_returned_simulation_rejects_an_invalid_policy_period_before_calculating( + forbid_database, +) -> None: + simulation = planned_simulation(SimulationRole.BASELINE).model_copy( + update={ + "policy": {"gov.irs.credits.ctc.amount.base[0].amount": {"2026": 3_000}} + } + ) + + with pytest.raises(ReportExecutionError) as raised: + _run_returned_simulation( + simulation, + calculator=lambda _: pytest.fail("calculation must not start"), + ) + + assert raised.value.failure.error_code == "invalid_policy_period" + assert raised.value.failure.error_summary == ( + "Policy parameter 'gov.irs.credits.ctc.amount.base[0].amount' has invalid " + "effective period '2026'; expected YYYY-MM-DD or " + "YYYY-MM-DD.YYYY-MM-DD." + ) + + +def test_returned_segmented_simulation_is_never_told_to_stop( + forbid_database, +) -> None: + # No record can carry a cancellation, so the coordinator stops this call + # through Modal instead. + simulation = planned_simulation(SimulationRole.BASELINE) + cancellation_answers = [] + + def segmented_calculator(planned, cancellation_requested): + cancellation_answers.append((planned, cancellation_requested())) + return SimulationCalculation(frames=simulation_frames()) + + _run_returned_simulation( + simulation, + calculator=lambda _: pytest.fail("the unsegmented calculator was selected"), + segmented_calculator=segmented_calculator, + ) + + assert cancellation_answers == [(simulation, False)] + + +def test_returned_segmented_simulation_keeps_the_segment_failure( + forbid_database, +) -> None: + segment_failure = Stage12FailureDetail( + error_code="segment_execution_failed", + error_summary="Segment 3: MemoryError in stage12_calculation", + ) + + def segmented_calculator(_planned, _cancellation_requested): + raise Stage12ExecutionError(segment_failure) + + with pytest.raises(ReportExecutionError) as raised: + _run_returned_simulation( + planned_simulation(SimulationRole.BASELINE), + calculator=lambda _: pytest.fail("the unsegmented calculator was selected"), + segmented_calculator=segmented_calculator, + ) + + assert raised.value.failure.error_code == "segment_execution_failed" + assert raised.value.failure.error_summary == ( + "Segment 3: MemoryError in stage12_calculation" + ) + + +def _macro_aggregate(**_inputs): + return aggregate_payload(CURRENT_SINGLE_YEAR_MACRO_RESULT) + + +def _coordinate_returned_report(invoker, *, context=None, report=None, **seams): + context = context or _returning_context() + seams.setdefault("store", NoDatabaseStore()) + seams.setdefault("artifacts", FakeArtifacts()) + seams.setdefault("aggregator", _macro_aggregate) + seams.setdefault("output_plan_resolver", lambda _: shared_output_plan()) + return coordinate_report_for_caller( + (report or report_execution_input()).model_dump(mode="json"), + context.model_dump(mode="json"), + invocation=CoordinatorInvocation( + application_name=context.modal_application, + invocation_id="coordinator-1", + ), + dependencies=CoordinatorDependencies(invoker=invoker, **seams), + ) + + +def test_returned_report_returns_its_typed_result_without_the_database( + forbid_database, +) -> None: + artifacts = FakeArtifacts() + invoker = ContextRecordingInvoker(artifacts) + aggregator_inputs = {} + + def aggregate(**inputs): + aggregator_inputs.update(inputs) + return _macro_aggregate() + + # The coordinator, not the caller, chooses where the artifacts go. + caller_context = _returning_context().model_copy( + update={"artifact_prefix": f"caller-chosen/{EVALUATION_ID}"} + ) + + returned = _coordinate_returned_report( + invoker, + context=caller_context, + artifacts=artifacts, + aggregator=aggregate, + ) + + result = ReportExecutionResult.model_validate(returned) + assert returned == result.model_dump(mode="json") + assert result.evaluation_id == EVALUATION_ID + assert result.requested_aggregates == tuple(ReportAggregate) + assert result.aggregate == SingleYearMacroOutput.model_validate( + CURRENT_SINGLE_YEAR_MACRO_RESULT + ) + assert result.aggregate_sha256 == report_aggregate_sha256(result.aggregate) + assert result.output_plan == shared_output_plan() + assert result.baseline.simulation_execution_id == BASELINE_ID + assert result.reform.simulation_execution_id == REFORM_ID + for descriptor in (result.baseline, result.reform): + stored = artifacts.read(descriptor.artifact.uri) + assert sha256(stored).hexdigest() == descriptor.artifact.content_sha256 + assert result.provenance == ReportExecutionProvenance( + bundle=bundle_provenance(), + worker_version="5.2.0", + modal_environment="staging", + modal_application="policyengine-simulation-v2-py5-2-0", + simulation_callable="run_single_simulation_us", + version_manifest_sha256="b" * 64, + coordinator_invocation_id="coordinator-1", + ) + assert result.completed_at.tzinfo is not None + household_income = { + role: aggregator_inputs[role] + .frames["household"]["household_net_income"] + .tolist() + for role in ("baseline", "reform") + } + assert household_income == {"baseline": [100.0], "reform": [120.0]} + # The aggregate is returned inline, never written as an artifact. + assert artifacts.aggregate_writes == [] + assert invoker.events == ["spawn:baseline", "spawn:reform"] + latest_start = max(interval[0] for interval in invoker.intervals.values()) + earliest_end = min(interval[1] for interval in invoker.intervals.values()) + assert latest_start < earliest_end + assert [context["result_delivery"] for context in invoker.child_contexts] == [ + "return_to_caller", + "return_to_caller", + ] + assert [context["artifact_prefix"] for context in invoker.child_contexts] == [ + f"stage-12-runs/staging/2026/09/{EVALUATION_ID}", + f"stage-12-runs/staging/2026/09/{EVALUATION_ID}", + ] + + +def test_returned_report_runs_only_the_stages_it_needs(forbid_database) -> None: + runtime = StageRecordingRuntime() + artifacts = FakeArtifacts() + + _coordinate_returned_report( + ConcurrentInvoker(artifacts), artifacts=artifacts, runtime=runtime + ) + + stage_name = STAGE12_CANONICAL_REPORT_STAGES.name + # No claim, child state or aggregate artifact stages: nothing is stored. + assert runtime.operations == [ + stage_name(Stage.STAGE12_COORDINATOR_PREPARATION), + stage_name(Stage.STAGE12_OUTPUT_PLANNING), + stage_name(Stage.STAGE12_CHILD_INPUT_PLANNING), + stage_name(Stage.STAGE12_CHILD_INPUT_PLANNING), + ] + assert runtime.spans == [ + stage_name(Stage.STAGE12_CHILD_DISPATCH), + stage_name(Stage.STAGE12_CHILD_DISPATCH), + stage_name(Stage.STAGE12_CHILD_WAIT), + stage_name(Stage.STAGE12_ARTIFACT_READ), + stage_name(Stage.STAGE12_AGGREGATION), + ] + + +def test_returned_report_claims_nothing_so_a_repeated_dispatch_runs_again( + forbid_database, +) -> None: + # The caller deduplicates; the coordinator keeps no record of the report. + artifacts = FakeArtifacts() + first_invoker = ConcurrentInvoker(artifacts) + repeated_invoker = ConcurrentInvoker(artifacts) + + first = _coordinate_returned_report(first_invoker, artifacts=artifacts) + repeated = _coordinate_returned_report(repeated_invoker, artifacts=artifacts) + + assert first_invoker.events == ["spawn:baseline", "spawn:reform"] + assert repeated_invoker.events == ["spawn:baseline", "spawn:reform"] + assert repeated["aggregate"] == first["aggregate"] + + +def test_returned_report_checks_the_dispatch_context_before_starting( + forbid_database, +) -> None: + invoker = ConcurrentInvoker(FakeArtifacts()) + + with pytest.raises(ValueError, match="application differs"): + coordinate_report_for_caller( + report_execution_input().model_dump(mode="json"), + _returning_context().model_dump(mode="json"), + invocation=CoordinatorInvocation( + application_name="policyengine-simulation-v2-py9-9-9", + invocation_id="coordinator-1", + ), + dependencies=CoordinatorDependencies( + store=NoDatabaseStore(), + artifacts=FakeArtifacts(), + invoker=invoker, + ), + ) + + assert invoker.events == [] + + +def test_returned_report_coordinator_refuses_a_stage12_context( + forbid_database, +) -> None: + invoker = ConcurrentInvoker(FakeArtifacts()) + + with pytest.raises(ValueError, match="return_to_caller"): + _coordinate_returned_report(invoker, context=invocation_context()) + + assert invoker.events == [] + + +def test_stage12_coordinator_refuses_a_returned_report_context() -> None: + store = FakeStore() + invoker = ConcurrentInvoker(FakeArtifacts()) + + with pytest.raises(ValueError, match="coordinate_report_for_caller"): + coordinate_report( + report_execution_input().model_dump(mode="json"), + _returning_context().model_dump(mode="json"), + parent_record().model_dump(mode="json"), + application_name=invocation_context().modal_application, + coordinator_invocation_id="coordinator-1", + store=store, + artifacts=FakeArtifacts(), + invoker=invoker, + aggregator=_macro_aggregate, + ) + + assert store.parent is None + assert invoker.events == [] + + +@pytest.mark.parametrize("segmented", [True, False]) +def test_returned_report_keeps_the_failed_childs_detail_and_stops_its_peer( + forbid_database, + segmented, +) -> None: + child_failure = ReportExecutionFailure( + evaluation_id=EVALUATION_ID, + error_code="SPM_YEAR_UNAVAILABLE", + error_summary="No SPM values for 2026", + ) + invoker = ScriptedChildInvoker( + wait_errors={"baseline": ReportExecutionError(child_failure)} + ) + runtime = StageRecordingRuntime() + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report( + invoker, + report=report_with_segmentation(segmented=segmented), + runtime=runtime, + ) + + assert raised.value.failure == child_failure + assert raised.value.__cause__ is None + assert raised.value.__suppress_context__ is True + # No record can tell a child to stop, segmented or not, so Modal cancels it. + assert invoker.calls["reform"].cancel_count == 1 + assert invoker.calls["baseline"].cancel_count == 0 + [recorded_cause] = runtime.recorded_exceptions + assert str(recorded_cause) == "No SPM values for 2026" + + +def test_returned_report_redacts_a_child_call_failure_without_detail( + forbid_database, +) -> None: + invoker = ScriptedChildInvoker( + wait_errors={"reform": LookupError("worker lost with token=opaque-token")} + ) + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report(invoker) + + failure = raised.value.failure + assert failure.evaluation_id == EVALUATION_ID + assert failure.error_code == "simulation_invocation_failed" + assert summary_failure_cause(failure.error_summary) == ( + "LookupError in stage12_child_wait (reform): worker lost with token=[REDACTED]" + ) + assert invoker.calls["baseline"].cancel_count == 1 + assert invoker.calls["reform"].cancel_count == 0 + + +@pytest.mark.parametrize("segmented", [True, False]) +def test_returned_report_wait_timeout_stops_every_child_through_modal( + forbid_database, + segmented, +) -> None: + invoker = ScriptedChildInvoker() + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report( + invoker, + report=report_with_segmentation(segmented=segmented), + wait_timeout_seconds=0, + ) + + assert raised.value.failure.error_code == "simulation_wait_timeout" + assert raised.value.failure.error_summary == ( + "Stage 12 simulations did not finish within 0 seconds of the report " + "coordinator starting." + ) + assert [call.cancel_count for call in invoker.calls.values()] == [1, 1] + + +@pytest.mark.parametrize("segmented", [True, False]) +def test_returned_report_stops_its_children_when_modal_cancels_it( + forbid_database, + segmented, +) -> None: + # Modal stops the coordinator's own cancelled input by raising + # InputCancellation in it, and does not cancel the calls it spawned. + def cancel_the_coordinator(): + msg = "Input was cancelled by user" + raise InputCancellation(msg) + + invoker = ScriptedChildInvoker(during_wait={"reform": cancel_the_coordinator}) + runtime = StageRecordingRuntime() + + with pytest.raises(InputCancellation): + _coordinate_returned_report( + invoker, + report=report_with_segmentation(segmented=segmented), + runtime=runtime, + ) + + assert [call.cancel_count for call in invoker.calls.values()] == [1, 1] + # A cancellation is not a report failure, so none is reported. + assert runtime.recorded_exceptions == [] + + +@pytest.mark.parametrize( + ("closing_role", "started_roles"), + [("baseline", ["baseline"]), ("reform", ["baseline", "reform"])], +) +def test_returned_report_stops_a_child_whose_dispatch_is_closing_when_cancelled( + forbid_database, + closing_role, + started_roles, +) -> None: + runtime = StageRecordingRuntime() + # The worker has started, but its dispatch span has not closed yet. + runtime.stop_on_close = ( + STAGE12_CANONICAL_REPORT_STAGES.name(Stage.STAGE12_CHILD_DISPATCH), + {"simulation_role": closing_role}, + ) + invoker = ScriptedChildInvoker() + + with pytest.raises(InputCancellation): + _coordinate_returned_report(invoker, runtime=runtime) + + assert list(invoker.calls) == started_roles + assert [call.cancel_count for call in invoker.calls.values()] == [1] * len( + started_roles + ) + + +def test_returned_report_dispatch_failure_stops_the_started_child( + forbid_database, +) -> None: + invoker = ReformDispatchFailureInvoker() + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report(invoker) + + failure = raised.value.failure + assert failure.error_code == "simulation_dispatch_failed" + assert summary_failure_cause(failure.error_summary) == ( + "ConnectionError in stage12_child_dispatch (reform): " + "dispatch contains sensitive details" + ) + assert invoker.events == ["spawn:baseline", "spawn:reform"] + assert invoker.calls["baseline"].cancel_count == 1 + + +def test_returned_report_cancellation_error_does_not_replace_the_failure( + forbid_database, + caplog, +) -> None: + invoker = ScriptedChildInvoker( + wait_errors={"reform": LookupError("reform worker failed")}, + cancel_errors={"baseline": ConnectionError("cancel contains sensitive data")}, + ) + + with ( + caplog.at_level(logging.WARNING, logger=coordination_module.__name__), + pytest.raises(ReportExecutionError) as raised, + ): + _coordinate_returned_report(invoker) + + assert raised.value.failure.error_code == "simulation_invocation_failed" + assert invoker.calls["baseline"].cancel_count == 1 + assert_log_identifies_child( + coordination_log_record(caplog, logging.WARNING), + role="baseline", + error_type="ConnectionError", + ) + + +def test_returned_report_aggregation_failure_is_redacted_and_reported_once( + forbid_database, +) -> None: + runtime = StageRecordingRuntime() + artifacts = FakeArtifacts() + + def fail(**_inputs): + msg = "aggregation read postgresql://runtime:database-secret@db.example/x" + raise RuntimeError(msg) + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report( + ConcurrentInvoker(artifacts), + artifacts=artifacts, + aggregator=fail, + runtime=runtime, + ) + + failure = raised.value.failure + assert failure.error_code == "report_coordination_failed" + assert summary_failure_cause(failure.error_summary) == ( + "RuntimeError in stage12_aggregation: " + "aggregation read postgresql://[REDACTED]@db.example/x" + ) + [recorded_cause] = runtime.recorded_exceptions + assert str(recorded_cause) == failure.error_summary + + +def test_returned_report_rejects_an_aggregate_outside_the_macro_output_schema( + forbid_database, +) -> None: + artifacts = FakeArtifacts() + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report( + ConcurrentInvoker(artifacts), + artifacts=artifacts, + aggregator=lambda **_: aggregate_payload({"budget": {}}), + ) + + failure = raised.value.failure + assert failure.error_code == "report_coordination_failed" + assert summary_failure_cause(failure.error_summary).startswith( + "ValidationError in stage12_aggregation: " + ) + assert artifacts.aggregate_writes == [] + + +def _aggregate_for_another_evaluation() -> dict: + return {**_macro_aggregate(), "evaluation_id": str(uuid4())} + + +def _aggregate_of_other_requested_aggregates() -> dict: + aggregate = _macro_aggregate() + return {**aggregate, "requested_aggregates": aggregate["requested_aggregates"][:1]} + + +def _aggregate_of_another_bundle() -> dict: + aggregate = _macro_aggregate() + return { + **aggregate, + "bundle": {**aggregate["bundle"], "country_package_version": "9.9.9"}, + } + + +@pytest.mark.parametrize( + "mismatched_aggregate", + [ + _aggregate_for_another_evaluation, + _aggregate_of_other_requested_aggregates, + _aggregate_of_another_bundle, + ], +) +def test_returned_report_rejects_an_aggregate_for_another_report( + forbid_database, + mismatched_aggregate, +) -> None: + artifacts = FakeArtifacts() + + def aggregate_for_another_report(**_inputs): + return mismatched_aggregate() + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report( + ConcurrentInvoker(artifacts), + artifacts=artifacts, + aggregator=aggregate_for_another_report, + ) + + assert raised.value.failure.error_code == "report_coordination_failed" + assert "aggregate does not describe this report" in ( + raised.value.failure.error_summary + ) + + +def test_returned_report_rejects_artifacts_that_do_not_follow_the_plan( + forbid_database, +) -> None: + artifacts = FakeArtifacts() + + with pytest.raises(ReportExecutionError) as raised: + _coordinate_returned_report( + ConcurrentInvoker( + artifacts, faults=InjectedChildFaults(wrong_plan_digest=True) + ), + artifacts=artifacts, + aggregator=lambda **_: pytest.fail("aggregation must not start"), + ) + + assert raised.value.failure.error_code == "report_coordination_failed" + assert "do not satisfy the report output plan" in ( + raised.value.failure.error_summary + ) + + +def test_stage12_report_still_requires_its_parent_record() -> None: + # The Modal coordinator's parent argument is optional now; a Stage 12 + # report without one is rejected before anything is claimed or started. + store = FakeStore() + invoker = ConcurrentInvoker(FakeArtifacts()) + + with pytest.raises(ValidationError): + coordinate_report( + report_execution_input().model_dump(mode="json"), + invocation_context().model_dump(mode="json"), + None, + application_name=invocation_context().modal_application, + coordinator_invocation_id="coordinator-1", + store=store, + artifacts=FakeArtifacts(), + invoker=invoker, + aggregator=_macro_aggregate, + ) + + assert store.parent is None + assert invoker.events == [] + + +# Modal restarts a pre-empted call on another machine, and the restarted call +# starts over. Its calculation can differ in the last bits from the first +# attempt's, so its output must not need to match what that attempt stored. + + +def _write_once_artifacts() -> Stage12ArtifactStore: + """Return an artifact store that, like the bucket, never replaces an object.""" + return Stage12ArtifactStore("private", store=InMemoryObjectStore()) + + +def test_a_restarted_returned_simulation_stores_output_that_differs_from_before( + forbid_database, +) -> None: + artifacts = _write_once_artifacts() + simulation = planned_simulation(SimulationRole.BASELINE) + + first_attempt = _run_returned_simulation( + simulation, artifacts=artifacts, calculator=lambda _: simulation_frames(100.0) + ) + restarted = _run_returned_simulation( + simulation, + artifacts=artifacts, + calculator=lambda _: simulation_frames(math.nextafter(100.0, math.inf)), + ) + + for result in (first_attempt, restarted): + descriptor = SimulationArtifactDescriptor.model_validate(result) + stored = artifacts.read(descriptor.artifact.uri) + assert sha256(stored).hexdigest() == descriptor.artifact.content_sha256 + assert first_attempt["artifact"]["uri"] != restarted["artifact"]["uri"] + + +def test_a_restarted_returned_simulation_with_the_same_output_reuses_its_artifact( + forbid_database, +) -> None: + artifacts = _write_once_artifacts() + simulation = planned_simulation(SimulationRole.BASELINE) + + first_attempt = _run_returned_simulation( + simulation, artifacts=artifacts, calculator=lambda _: simulation_frames(100.0) + ) + restarted = _run_returned_simulation( + simulation, artifacts=artifacts, calculator=lambda _: simulation_frames(100.0) + ) + + assert restarted == first_attempt + + +def test_a_restarted_returned_simulation_stores_an_input_that_differs_from_before( + forbid_database, +) -> None: + objects = InMemoryObjectStore() + artifacts = Stage12ArtifactStore("private", store=objects) + simulation = planned_simulation(SimulationRole.BASELINE) + # A restart that lands on a redeployed application can plan the same + # simulation differently; any change to the planned input stands for it. + replanned = simulation.model_copy( + update={"policy": {"reform": {"2026-01-01.2026-12-31": 1}}} + ) + + _run_returned_simulation( + simulation, artifacts=artifacts, calculator=lambda _: simulation_frames(100.0) + ) + _run_returned_simulation( + replanned, artifacts=artifacts, calculator=lambda _: simulation_frames(100.0) + ) + + stored_inputs = [key for key in objects.objects if "/inputs/" in key] + assert len(stored_inputs) == 2 + + +def test_a_stage12_simulation_keeps_its_fixed_artifact_keys() -> None: + objects = InMemoryObjectStore() + artifacts = Stage12ArtifactStore("private", store=objects) + simulation = planned_simulation(SimulationRole.BASELINE) + store = FakeStore() + store.children[simulation.simulation_execution_id] = child_record(simulation) + + result = run_single_simulation( + simulation.model_dump(mode="json"), + invocation_context().model_dump(mode="json"), + required_country="us", + dependencies=SimulationWorkerDependencies( + store=store, + artifacts=artifacts, + calculator=lambda _: simulation_frames(100.0), + ), + ) + + prefix = invocation_context().artifact_prefix + assert result["artifact"]["uri"] == ( + f"gs://private/{prefix}/simulations/baseline.parquet" + ) + assert sorted(objects.objects) == [ + f"{prefix}/inputs/baseline.json", + f"{prefix}/simulations/baseline.parquet", + ] + + +class InProcessWorkerCall: + """A started worker whose outcome was computed when it was started.""" + + def __init__(self, object_id, outcome) -> None: + self.object_id = object_id + self.outcome = outcome + self.cancel_count = 0 + + def get(self, *, timeout=None): + if isinstance(self.outcome, BaseException): + raise self.outcome + return self.outcome + + def cancel(self) -> None: + self.cancel_count += 1 + + +class InProcessWorkerInvoker: + """Run each returned worker in this process, storing into ``artifacts``. + + ``household_income`` gives each role's calculated household net income. + Probing ``interrupted_role``'s result raises ``KeyboardInterrupt``, as the + interrupt Modal sends a pre-empted container does in the coordinator. + """ + + def __init__(self, artifacts, *, household_income, interrupted_role=None): + self.artifacts = artifacts + self.household_income = household_income + self.interrupted_role = interrupted_role + self.calls = {} + + def spawn(self, request): + role = PlannedSimulationExecutionInput.model_validate( + request.simulation + ).role.value + if role == self.interrupted_role: + outcome = KeyboardInterrupt() + else: + income = self.household_income[role] + outcome = run_single_simulation( + request.simulation, + request.context, + required_country="us", + dependencies=SimulationWorkerDependencies( + artifacts=self.artifacts, + calculator=lambda _: simulation_frames(income), + ), + ) + call = InProcessWorkerCall(f"call-{role}", outcome) + self.calls[role] = call + return call + + +def _report_with_a_dated_reform_period(): + """Return the test report with a reform period the worker's check accepts.""" + report = report_execution_input() + return report.model_copy( + update={ + "reform": report.reform.model_copy( + update={"policy": {"reform": {"2026-01-01.2026-12-31": 1}}} + ) + } + ) + + +def test_a_pre_empted_returned_report_finishes_when_modal_restarts_it( + forbid_database, +) -> None: + artifacts = _write_once_artifacts() + report = _report_with_a_dated_reform_period() + first_attempt = InProcessWorkerInvoker( + artifacts, + household_income={"baseline": 100.0}, + interrupted_role="reform", + ) + + # The baseline worker stores its output before Modal pre-empts the + # coordinator, which then cancels the unfinished reform worker. + with pytest.raises(KeyboardInterrupt): + _coordinate_returned_report(first_attempt, report=report, artifacts=artifacts) + assert first_attempt.calls["reform"].cancel_count == 1 + + restarted = InProcessWorkerInvoker( + artifacts, + household_income={ + "baseline": math.nextafter(100.0, math.inf), + "reform": 120.0, + }, + ) + returned = _coordinate_returned_report( + restarted, report=report, artifacts=artifacts + ) + + result = ReportExecutionResult.model_validate(returned) + for descriptor in (result.baseline, result.reform): + stored = artifacts.read(descriptor.artifact.uri) + assert sha256(stored).hexdigest() == descriptor.artifact.content_sha256 diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_segment_fanout.py b/projects/policyengine-simulation-executor/tests/test_stage12_segment_fanout.py index a21794b09..a4251256b 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_segment_fanout.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_segment_fanout.py @@ -8,6 +8,7 @@ import pandas as pd import pytest +from fixtures.fake_modal import InputCancellation from policyengine_simulation_contract.stage12_execution import SimulationRole from policyengine_simulation_executor.stage12_runtime import ( SegmentRunnerSeams, @@ -21,6 +22,10 @@ from policyengine_simulation_executor.stage12_runtime.partition import ( US_REGION_GROUPS, ) +from policyengine_simulation_observability.stages import ( + STAGE12_SIMULATION_STAGES, + Stage, +) from stage12_runtime_support import planned_simulation if TYPE_CHECKING: @@ -49,6 +54,10 @@ class FakeRuntime: def __init__(self) -> None: self.context: dict[str, object] = {} self.spans: list[tuple[str, dict | None]] = [] + # Raise this when the span with this name and attributes opens or + # closes, as Modal's cancellation can arrive at any point. + self.stop_on_open: tuple[str, dict | None] | None = None + self.stop_on_close: tuple[str, dict | None] | None = None def set_context(self, **attributes) -> None: self.context.update(attributes) @@ -59,7 +68,13 @@ def capture_context(self) -> dict[str, str]: @contextmanager def span(self, name, *, attributes=None): self.spans.append((name, attributes)) + if self.stop_on_open == (name, attributes): + msg = "Input was cancelled by user" + raise InputCancellation(msg) yield + if self.stop_on_close == (name, attributes): + msg = "Input was cancelled by user" + raise InputCancellation(msg) class FakeCall: @@ -80,7 +95,9 @@ def cancel(self) -> None: class FakeFunction: - def __init__(self, results: list[dict], errors: dict[int, list[Exception]]): + def __init__( + self, results: list[dict], errors: dict[int, list[BaseException]] + ) -> None: self.results = results self.errors = errors self.spawns: list[tuple[dict, dict | None]] = [] @@ -106,7 +123,7 @@ def _results(simulation) -> list[dict]: ] -def _runner(*, errors: dict[int, list[Exception]] | None = None): +def _runner(*, errors: dict[int, list[BaseException]] | None = None): simulation = planned_simulation(SimulationRole.BASELINE) function = FakeFunction(_results(simulation), errors or {}) @@ -219,3 +236,66 @@ def test_persisted_parent_cancellation_cancels_all_segments() -> None: assert sleeps == [0.5] assert all(call.cancelled == 1 for call in function.calls) + + +def test_modal_cancelling_the_worker_cancels_every_started_segment() -> None: + # Modal stops a cancelled input by raising InputCancellation in it, and does + # not cancel the calls that input spawned. + runner, function, _, _ = _runner( + errors={3: [InputCancellation("Input was cancelled by user")]} + ) + + with pytest.raises(InputCancellation): + runner.run() + + assert len(function.calls) == 20 + assert all(call.cancelled == 1 for call in function.calls) + + +def test_modal_cancelling_the_worker_during_dispatch_cancels_started_segments() -> None: + runner, function, _, _ = _runner() + spawn = function.spawn + + def cancel_during_dispatch(payload, *, observability_context): + if payload["segment_index"] == 5: + msg = "Input was cancelled by user" + raise InputCancellation(msg) + return spawn(payload, observability_context=observability_context) + + function.spawn = cancel_during_dispatch + + with pytest.raises(InputCancellation): + runner.run() + + assert len(function.calls) == 5 + assert all(call.cancelled == 1 for call in function.calls) + + +def test_modal_cancelling_the_worker_as_a_dispatch_span_closes_cancels_it() -> None: + runner, function, runtime, _ = _runner() + # The call has started, but its dispatch span has not closed yet. + runtime.stop_on_close = ( + STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SEGMENT_DISPATCH), + {"segment_index": 3}, + ) + + with pytest.raises(InputCancellation): + runner.run() + + assert len(function.calls) == 4 + assert all(call.cancelled == 1 for call in function.calls) + + +def test_modal_cancelling_the_worker_before_the_wait_cancels_every_segment() -> None: + runner, function, runtime, _ = _runner() + # Every segment has started; the wait has not begun. + runtime.stop_on_open = ( + STAGE12_SIMULATION_STAGES.name(Stage.STAGE12_SEGMENT_WAIT), + None, + ) + + with pytest.raises(InputCancellation): + runner.run() + + assert len(function.calls) == 20 + assert all(call.cancelled == 1 for call in function.calls) 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.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.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/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/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.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/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/fixtures/sim.persistence.public-api-copies-match/bad_copies.json b/rules/fixtures/sim.persistence.public-api-copies-match/bad_copies.json new file mode 100644 index 000000000..146fabe5a --- /dev/null +++ b/rules/fixtures/sim.persistence.public-api-copies-match/bad_copies.json @@ -0,0 +1,257 @@ +{ + "tables": { + "datasets": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "name": { + "type": "character varying(255)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_datasets": [ + "id" + ], + "uq_datasets_id_model_version": [ + "id", + "tax_benefit_model_version_id" + ], + "uq_datasets_model_version_name": [ + "tax_benefit_model_version_id", + "name" + ] + } + }, + "dynamics": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_dynamics": [ + "id" + ] + } + }, + "households": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "country_id": { + "type": "character varying(2)", + "nullable": false + } + }, + "unique_constraints": { + "pk_households": [ + "id" + ], + "uq_households_id_country": [ + "id", + "country_id" + ] + } + }, + "parameter_values": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "parameter_id": { + "type": "uuid", + "nullable": false + }, + "value_json": { + "type": "jsonb", + "nullable": false + }, + "start_date": { + "type": "timestamp with time zone", + "nullable": false + }, + "end_date": { + "type": "timestamp with time zone", + "nullable": true + }, + "policy_id": { + "type": "uuid", + "nullable": true + }, + "dynamic_id": { + "type": "uuid", + "nullable": true + } + }, + "unique_constraints": { + "pk_parameter_values": [ + "id" + ] + } + }, + "parameters": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "name": { + "type": "character varying(512)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_parameters": [ + "id" + ], + "uq_parameters_model_version_name": [ + "tax_benefit_model_version_id", + "name" + ] + } + }, + "policies": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "country_id": { + "type": "character varying(2)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_policies": [ + "id" + ], + "uq_policies_id_country": [ + "id", + "country_id" + ] + } + }, + "regions": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "code": { + "type": "character varying(255)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + }, + "default_dataset_id": { + "type": "uuid", + "nullable": false + }, + "label": { + "type": "character varying(255)", + "nullable": false + }, + "region_type": { + "type": "character varying(64)", + "nullable": false + }, + "requires_filter": { + "type": "boolean", + "nullable": false + }, + "filter_field": { + "type": "character varying(128)", + "nullable": true + }, + "filter_value": { + "type": "character varying(255)", + "nullable": true + }, + "filter_strategy": { + "type": "character varying(64)", + "nullable": true + }, + "state_code": { + "type": "character varying(16)", + "nullable": true + } + }, + "unique_constraints": { + "pk_regions": [ + "id" + ], + "uq_regions_model_version_code": [ + "tax_benefit_model_version_id", + "code" + ] + } + }, + "tax_benefit_model_versions": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "model_id": { + "type": "uuid", + "nullable": false + }, + "version": { + "type": "character varying(128)", + "nullable": false + } + }, + "unique_constraints": { + "pk_tax_benefit_model_versions": [ + "id" + ], + "uq_tax_benefit_model_versions_model_version": [ + "model_id", + "version" + ] + } + }, + "tax_benefit_models": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "name": { + "type": "character varying(32)", + "nullable": false + } + }, + "unique_constraints": { + "pk_tax_benefit_models": [ + "id" + ], + "uq_tax_benefit_models_name": [ + "name" + ] + } + } + }, + "enums": {} +} diff --git a/rules/fixtures/sim.persistence.public-api-copies-match/good_copies.json b/rules/fixtures/sim.persistence.public-api-copies-match/good_copies.json new file mode 100644 index 000000000..6462aa241 --- /dev/null +++ b/rules/fixtures/sim.persistence.public-api-copies-match/good_copies.json @@ -0,0 +1,268 @@ +{ + "tables": { + "datasets": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "name": { + "type": "character varying(255)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_datasets": [ + "id" + ], + "uq_datasets_id_model_version": [ + "id", + "tax_benefit_model_version_id" + ], + "uq_datasets_model_version_name": [ + "tax_benefit_model_version_id", + "name" + ] + } + }, + "dynamics": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_dynamics": [ + "id" + ] + } + }, + "households": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "country_id": { + "type": "character varying(2)", + "nullable": false + } + }, + "unique_constraints": { + "pk_households": [ + "id" + ], + "uq_households_id_country": [ + "id", + "country_id" + ] + } + }, + "parameter_values": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "parameter_id": { + "type": "uuid", + "nullable": false + }, + "value_json": { + "type": "jsonb", + "nullable": false + }, + "start_date": { + "type": "timestamp with time zone", + "nullable": false + }, + "end_date": { + "type": "timestamp with time zone", + "nullable": true + }, + "policy_id": { + "type": "uuid", + "nullable": true + }, + "dynamic_id": { + "type": "uuid", + "nullable": true + } + }, + "unique_constraints": { + "pk_parameter_values": [ + "id" + ] + } + }, + "parameters": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "name": { + "type": "character varying(512)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_parameters": [ + "id" + ], + "uq_parameters_model_version_name": [ + "tax_benefit_model_version_id", + "name" + ] + } + }, + "policies": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "country_id": { + "type": "character varying(2)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_policies": [ + "id" + ], + "uq_policies_id_country": [ + "id", + "country_id" + ] + } + }, + "regions": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "code": { + "type": "character varying(255)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + }, + "default_dataset_id": { + "type": "uuid", + "nullable": false + }, + "label": { + "type": "character varying(255)", + "nullable": false + }, + "region_type": { + "type": "enum v2_region_type", + "nullable": false + }, + "requires_filter": { + "type": "boolean", + "nullable": false + }, + "filter_field": { + "type": "character varying(128)", + "nullable": true + }, + "filter_value": { + "type": "character varying(255)", + "nullable": true + }, + "filter_strategy": { + "type": "character varying(64)", + "nullable": true + }, + "state_code": { + "type": "character varying(16)", + "nullable": true + } + }, + "unique_constraints": { + "pk_regions": [ + "id" + ], + "uq_regions_model_version_code": [ + "tax_benefit_model_version_id", + "code" + ] + } + }, + "tax_benefit_model_versions": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "model_id": { + "type": "uuid", + "nullable": false + }, + "version": { + "type": "character varying(128)", + "nullable": false + } + }, + "unique_constraints": { + "pk_tax_benefit_model_versions": [ + "id" + ], + "uq_tax_benefit_model_versions_model_version": [ + "model_id", + "version" + ] + } + }, + "tax_benefit_models": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "name": { + "type": "character varying(32)", + "nullable": false + } + }, + "unique_constraints": { + "pk_tax_benefit_models": [ + "id" + ], + "uq_tax_benefit_models_name": [ + "name" + ] + } + } + }, + "enums": { + "v2_region_type": [ + "national", + "country", + "state", + "congressional_district", + "constituency", + "local_authority", + "city", + "place" + ] + } +} diff --git a/rules/fixtures/sim.persistence.public-api-copies-match/public_api_schema.json b/rules/fixtures/sim.persistence.public-api-copies-match/public_api_schema.json new file mode 100644 index 000000000..77052f017 --- /dev/null +++ b/rules/fixtures/sim.persistence.public-api-copies-match/public_api_schema.json @@ -0,0 +1,433 @@ +{ + "tables": { + "datasets": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "updated_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "name": { + "type": "character varying(255)", + "nullable": false + }, + "description": { + "type": "character varying", + "nullable": true + }, + "storage_path": { + "type": "character varying(1024)", + "nullable": true + }, + "year": { + "type": "integer", + "nullable": false + }, + "is_output_dataset": { + "type": "boolean", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_datasets": [ + "id" + ], + "uq_datasets_id_model_version": [ + "id", + "tax_benefit_model_version_id" + ], + "uq_datasets_model_version_name": [ + "tax_benefit_model_version_id", + "name" + ] + } + }, + "dynamics": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "updated_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "name": { + "type": "character varying(255)", + "nullable": false + }, + "description": { + "type": "character varying", + "nullable": true + } + }, + "unique_constraints": { + "pk_dynamics": [ + "id" + ] + } + }, + "households": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "updated_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "household_data": { + "type": "jsonb", + "nullable": false + }, + "country_id": { + "type": "character varying(2)", + "nullable": false + }, + "default_year": { + "type": "integer", + "nullable": true + }, + "canonicalization_version": { + "type": "integer", + "nullable": false + }, + "content_hash": { + "type": "character varying(64)", + "nullable": false + } + }, + "unique_constraints": { + "pk_households": [ + "id" + ], + "uq_households_canonicalization_content_hash": [ + "canonicalization_version", + "content_hash" + ], + "uq_households_id_country": [ + "id", + "country_id" + ] + } + }, + "parameter_values": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "parameter_id": { + "type": "uuid", + "nullable": false + }, + "value_json": { + "type": "jsonb", + "nullable": false + }, + "start_date": { + "type": "timestamp with time zone", + "nullable": false + }, + "end_date": { + "type": "timestamp with time zone", + "nullable": true + }, + "policy_id": { + "type": "uuid", + "nullable": true + }, + "dynamic_id": { + "type": "uuid", + "nullable": true + } + }, + "unique_constraints": { + "pk_parameter_values": [ + "id" + ], + "uq_parameter_values_policy_parameter_start_date": [ + "policy_id", + "parameter_id", + "start_date" + ] + } + }, + "parameters": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "name": { + "type": "character varying(512)", + "nullable": false + }, + "label": { + "type": "character varying(512)", + "nullable": true + }, + "description": { + "type": "character varying", + "nullable": true + }, + "data_type": { + "type": "character varying(128)", + "nullable": true + }, + "unit": { + "type": "character varying(128)", + "nullable": true + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_parameters": [ + "id" + ], + "uq_parameters_model_version_name": [ + "tax_benefit_model_version_id", + "name" + ] + } + }, + "policies": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "updated_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "tax_benefit_model_id": { + "type": "uuid", + "nullable": false + }, + "country_id": { + "type": "character varying(2)", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + }, + "canonicalization_version": { + "type": "integer", + "nullable": false + }, + "content_hash": { + "type": "character varying(64)", + "nullable": false + } + }, + "unique_constraints": { + "pk_policies": [ + "id" + ], + "uq_policies_canonicalization_content_hash": [ + "canonicalization_version", + "content_hash" + ], + "uq_policies_id_country": [ + "id", + "country_id" + ] + } + }, + "regions": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "updated_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "code": { + "type": "character varying(255)", + "nullable": false + }, + "label": { + "type": "character varying(255)", + "nullable": false + }, + "region_type": { + "type": "enum v2_region_type", + "nullable": false + }, + "requires_filter": { + "type": "boolean", + "nullable": false + }, + "filter_field": { + "type": "character varying(128)", + "nullable": true + }, + "filter_value": { + "type": "character varying(255)", + "nullable": true + }, + "filter_strategy": { + "type": "character varying(64)", + "nullable": true + }, + "parent_code": { + "type": "character varying(255)", + "nullable": true + }, + "state_code": { + "type": "character varying(16)", + "nullable": true + }, + "state_name": { + "type": "character varying(128)", + "nullable": true + }, + "default_dataset_id": { + "type": "uuid", + "nullable": false + }, + "tax_benefit_model_version_id": { + "type": "uuid", + "nullable": false + } + }, + "unique_constraints": { + "pk_regions": [ + "id" + ], + "uq_regions_model_version_code": [ + "tax_benefit_model_version_id", + "code" + ] + } + }, + "tax_benefit_model_versions": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "model_id": { + "type": "uuid", + "nullable": false + }, + "version": { + "type": "character varying(128)", + "nullable": false + }, + "description": { + "type": "character varying", + "nullable": true + }, + "current_law_id": { + "type": "integer", + "nullable": false + }, + "metadata_time_periods": { + "type": "json", + "nullable": false + } + }, + "unique_constraints": { + "pk_tax_benefit_model_versions": [ + "id" + ], + "uq_tax_benefit_model_versions_model_version": [ + "model_id", + "version" + ] + } + }, + "tax_benefit_models": { + "columns": { + "id": { + "type": "uuid", + "nullable": false + }, + "created_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "updated_at": { + "type": "timestamp with time zone", + "nullable": false + }, + "name": { + "type": "character varying(32)", + "nullable": false + }, + "description": { + "type": "character varying", + "nullable": true + } + }, + "unique_constraints": { + "pk_tax_benefit_models": [ + "id" + ], + "uq_tax_benefit_models_name": [ + "name" + ] + } + } + }, + "enums": { + "v2_region_type": [ + "national", + "country", + "state", + "congressional_district", + "constituency", + "local_authority", + "city", + "place" + ] + } +} diff --git a/rules/registry.toml b/rules/registry.toml index f298eb988..7c87c43ae 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,190 @@ 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 = "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.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.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.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" +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 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", "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_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", + "tests/test_ownership_boundaries.py::test_a_temporary_modal_app_may_not_receive_another_database_url", +] +[[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" + +[[rule]] +id = "sim.persistence.migrate-before-entrypoint" +category = "persistence" +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" 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"] +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", + "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/" [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" +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_stage12_schema_authority.py::test_rule_proof_stage12_no_schema_ddl" +test = "tests/test_simulation_database_deployment.py::test_rule_proof_migrate_before_entrypoint" + +[[rule]] +id = "sim.persistence.public-api-copies-match" +category = "persistence" +statement = "The persistence tests' copies of the Public API's tables match the tables the Public API's v2 migration chain builds at its default branch: each copied column's type (an enum by name and values) and nullability, and each copied primary key and unique constraint by name and columns." +rationale = "libs/policyengine-simulation-persistence/README.md#copies-of-the-public-apis-tables" +scope = ["libs/policyengine-simulation-persistence/tests/public_api_support.py", ".github/scripts/public-api-schema-reference.sh"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "libs/policyengine-simulation-persistence" +nodes = [ + "tests/test_public_api_schema.py::test_the_copies_of_the_public_api_tables_match_its_schema", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "public-api-schema" +invocation = "uv run --frozen pytest tests/ -v -m public_api_schema" +[rule.proof] +bad_fixture = "rules/fixtures/sim.persistence.public-api-copies-match/bad_copies.json" +good_fixture = "rules/fixtures/sim.persistence.public-api-copies-match/good_copies.json" +project = "libs/policyengine-simulation-persistence" +test = "tests/test_public_api_schema.py::test_rule_proof_public_api_copies_match" + +# --- Stage 12 persistence -------------------------------------------------- [[rule]] -id = "sim.stage12.no-migration-chain" +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" @@ -317,9 +463,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, 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"] +scope = [".github/workflows/*.yml", ".github/scripts/deploy-cloud-run-simulation-entry.sh"] tier = "mechanical" status = "enforced" autofix = false @@ -329,11 +475,19 @@ 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", + "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" @@ -377,6 +531,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" @@ -533,9 +712,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 @@ -544,12 +723,18 @@ 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]] 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" @@ -1067,11 +1252,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 +1270,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",