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..54d830beb --- /dev/null +++ b/.github/scripts/deploy-cloud-run-simulation-safety-check.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Point the safety-check Cloud Run job at the entrypoint revision that serves +# traffic, and keep its Cloud Scheduler entry. +# +# The job runs `python -m policyengine_simulation_entry.safety_check`: one +# recording pass per execution. It is updated only from a revision that was +# promoted (or restored by a rollback), so a rejected candidate never records +# anything. It copies the revision's image digest, service account, Modal +# token and database URL (at the secret versions the revision pins) and its +# plain telemetry settings; nothing else. A revision 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|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/modal-image-smoke.sh b/.github/scripts/modal-image-smoke.sh index dc9c0062f..b8aede54e 100755 --- a/.github/scripts/modal-image-smoke.sh +++ b/.github/scripts/modal-image-smoke.sh @@ -3,8 +3,10 @@ # Usage: ./modal-image-smoke.sh # # Each smoke imports the true app entrypoints inside the real (or, for -# the executor, prefix-identical) Modal image — catching in-image -# dependency breakage (issue #602's class) before merge. +# the executor and the v2 US worker, prefix-identical) Modal image — +# catching in-image dependency breakage (issue #602's class) before merge. +# The v2 image smoke builds every image of the v2 application: the report +# coordinator, the country simulation workers and the household functions. set -euo pipefail @@ -47,4 +49,10 @@ echo "=== Stage 12 static runtime file smoke (env: $MODAL_ENV) ===" src/modal/v2_static_runtime_files_smoke.py ) +echo "=== v2 image smoke: every v2 image and its entry points (env: $MODAL_ENV) ===" +( + cd "$REPO_ROOT/projects/policyengine-simulation-executor" + uv run --frozen modal run --env="$MODAL_ENV" src/modal/v2_image_smoke.py +) + echo "=== Image smokes passed ===" diff --git a/.github/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-image-smoke.yml b/.github/workflows/pr-image-smoke.yml index 730b517d5..fd00793b4 100644 --- a/.github/workflows/pr-image-smoke.yml +++ b/.github/workflows/pr-image-smoke.yml @@ -33,9 +33,10 @@ jobs: # Same-repository pull requests only, and in a stack only the lowest # unmerged one (see docs/engineering/skills/github-prs.md). if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (!github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref) }} - # Warm cache: ~2 min. After a relock the executor smoke pays the - # certified dataset download layer (~15-20 min). - timeout-minutes: 45 + # Warm cache: a few minutes. After a relock the executor smoke and the + # v2 image smoke each pay their certified dataset download layers + # (~15-20 min each; Modal builds the v2 smoke's images together). + timeout-minutes: 90 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0c27df2f1..ac46f768b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: # every job passed. See docs/engineering/skills/github-prs.md. name: CI complete if: ${{ always() }} - needs: [test, lint, pyright, quality-guards, docker-build, test-integration] + needs: [test, simulation-database, lint, pyright, quality-guards, docker-build, test-integration] runs-on: ubuntu-latest permissions: contents: read @@ -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,54 @@ jobs: working-directory: ${{ matrix.project }} run: uv run --frozen pytest tests/ -v + simulation-database: + name: Simulation database + # Runs the simulation schema's migration chain (upgrade, alembic check, + # downgrade, upgrade) and the persistence tests that need a database, on a + # fresh PostgreSQL. Full CI runs only on the lowest unmerged pull request + # in a stack. + if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }} + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: simulation_ci + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + env: + SIMULATION_TEST_DATABASE_URL: postgresql://postgres:simulation_ci@127.0.0.1:5432/postgres + # A missing server fails the tests instead of skipping them. + SIMULATION_REQUIRE_DATABASE_TESTS: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install dependencies + working-directory: libs/policyengine-simulation-persistence + run: uv sync --extra test --frozen + + - name: Run the migration chain and persistence tests on PostgreSQL + working-directory: libs/policyengine-simulation-persistence + run: uv run --frozen pytest tests/ -v -m postgres + lint: name: Lint # Full CI runs only on the lowest unmerged pull request in a stack. @@ -157,6 +206,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..0b204fc61 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,59 @@ 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 + + staging_household_test: + name: Calculate a household on the promoted staging revision + # The household function reports the call's end to the stable URL, which + # serves the promoted revision, so this runs after promotion, in staging + # only, and only when this deploy deployed the v2 application. + if: >- + ${{ + !cancelled() && + inputs.deployment_environment == 'staging' && + inputs.deploy_stage12_v2 && + needs.promote_entrypoint.result == 'success' + }} + needs: [prepare, deploy_entrypoint, promote_entrypoint] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + env: + SIMULATION_STAGING_HOUSEHOLD_TEST: "1" + SIMULATION_STAGING_TEST_BASE_URL: ${{ needs.deploy_entrypoint.outputs.stable_url }} + SIMULATION_STAGING_TEST_POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + SIMULATION_STAGING_TEST_PUBLIC_API_URL: ${{ vars.PUBLIC_API_URL }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install staging-test dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - name: Submit a household calculation and read its result back + working-directory: projects/policyengine-simulation-entry + run: uv run --frozen pytest authenticated_tests/test_staging_household_calculation.py -v + finalize_routing: name: Finalize the Modal routing publication # Finish the durable journal after every gate, including failed uploads diff --git a/.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..a278c542c 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` ### 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..fa423970b 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -2,6 +2,10 @@ changes: added: - Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced + - Added household calculations on v2 to the Simulation Entrypoint; POST /v2/household-calculations takes the IDs of a stored household and policy (none for current law), its options, such as axes, and a PolicyEngine.py version, reads the household, the policy's parameter values and the catalog version from the Public API's tables, records the calculation for the household's default_year as a simulation run and starts the v2 household function's Modal call, and GET /v2/household-calculations/{calculation_id} returns its status and, once it ended, the calculated household with its warnings, the SPM selection it applied (spm_config), its SPM receipt (spm_provenance) and the bundle, or its error; the same calculation reuses its run while unfinished or for 28 days after it succeeded, unless forced; the runtime role needs SELECT on the Public API's tax_benefit_models, tax_benefit_model_versions, households, policies, parameters and parameter_values + - Added household reports on v2; POST /v2/household-reports creates a report of a stored household under a baseline and an optional reform, each by policy ID, and starts its first run, POST /v2/household-reports/{report_id}/runs starts another on a PolicyEngine.py version, and GET /v2/household-reports/{report_id} returns the newest run with both calculations, whose pending runs one Modal call computes together + - Made the v2 household functions report their call's end to the Simulation Entrypoint as their last step, given the runs a call computes, and added a household result recorder, so each household call's outcome is recorded when it reports, or by the safety check; a household function's typed failure fails the runs with household_calculation_failed and keeps its error as the runs' error detail (revision 0005 adds simulation options, the household output's warnings, SPM documents and bundle, and error details) + - Added a staging household test that runs after each staging deployment promotes its revision: it stores a US household through the staging Public API, submits its ID to the stable URL and reads the result back within ten minutes; it reads the staging Public API's URL from the new beta environment variable PUBLIC_API_URL - Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput - Declared local_area_breakdown version 1 in the routing bundle manifest of apps whose policyengine.py bundle routes both UK area region types to one local-area release dataset (regional_release_kind local_area; an area_coded_national bundle, or one routing the two types to different datasets, does not count as routed), and made the gateway refuse breakdown with 400, before spawning, for any route whose manifest does not declare it, including legacy routes and routes pinned to an older version, since older apps ignore the field and would return a national result - Made a routed local-area dataset fail as it loads, before any simulation, when it lacks any of the four household columns the area outputs read (constituency_code_oa, la_code_oa, region_code_oa and source_household_id), for breakdown, constituency and local-authority runs alike, in the runtime and in the Stage 12 worker; Stage 12 copies the same columns @@ -27,6 +31,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..d19767d05 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 | 58 | 2 | 0 | 14 | 74 | | metric | 1 | 0 | 0 | 0 | 1 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 56 of 70 rules. +**Blocking:** 61 of 75 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,43 @@ 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 | + +```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_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 +``` + ## 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 +181,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_promotion.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 +213,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 +239,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_promotion_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 +339,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 +372,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 +406,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/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index 4ecb5fc0e..fa6e616c4 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -409,20 +409,50 @@ and coordinator images do not contain these files. Deployment validation runs one real 2026 region-group baseline and requires it to load the precomputed file without recalculation. -Readers released before cache releases reject fields they do not know, and +Manifest schema version 2 records, for each country a worker application +serves, whether it is economy-capable (it has a single-simulation worker, so +its reports may be given to the report coordinator) and which function +calculates its households. The Simulation Entrypoint's comparison adapter +skips any country the manifest does not mark as economy-capable, with the +reason `unsupported_country`, so the coordinator receives only those +countries' reports. Its household calculation routes look a calculation's +household function up by its PolicyEngine.py version and country, and pass +the simulation runs the call computes as the function's second argument; the +function's last step then reports the call's end to the entrypoint +(`src/modal/call_reports.py`), which records its outcome. Release validation +and the image check call the household functions without runs, and nothing is +reported for them. + +Readers released before a schema change reject fields they do not know, and the manifest is published before the Cloud Run revision that reads it is -promoted. The publisher therefore writes the whole manifest under the -`active-with-region-datasets` key, the same manifest without each country -bundle's `region_datasets` under the `active-with-cache` key, and without cache -releases either under the `active` key; readers released before each field keep -reading the key that lacks it. Current readers use the -`active-with-region-datasets` copy only while it, with its region datasets -removed, equals the manifest under `active-with-cache`; otherwise they read -`active-with-cache`, falling back to `active` before the first publish that -writes it. Code released before region datasets republishes only the two older -keys, so after a rollback the out-of-date region dataset copy stops matching and -is no longer read. Removing the older copies needs a later change, once no -deployed or rollback revision reads them. +promoted. The publisher therefore writes the whole schema version 2 manifest +under the `active-schema-2` key and, in schema version 1 form (without the +capability and household fields), the same manifest under the +`active-with-region-datasets` key, without each country bundle's +`region_datasets` under the `active-with-cache` key, and without cache releases +either under the `active` key; readers released before each field keep reading +the key that lacks it. A schema version 1 document marks every listed country +economy-capable, with no household function. + +Current readers use the `active-schema-2` document while each older key holds +its schema version 1 form. Schema version 1 cannot describe a country that is +not economy-capable, so a manifest holding one is written only under +`active-schema-2` and the older keys keep the last manifest written there; such +a document is used unless the document under the older keys was validated +later. Otherwise readers use the `active-with-region-datasets` copy only while +it, with its region datasets removed, equals the manifest under +`active-with-cache`; otherwise they read `active-with-cache`, falling back to +`active` before the first publish that writes it. Code released before schema +version 2 or before region datasets republishes only the older keys it knows, so +after a rollback the out-of-date copies stop matching and are no longer read, +and the next publish starts from what the older code wrote. That document has +no household functions, so the manifest names none for versions published +before the rollback until each is published again, and it lacks whatever only +`active-schema-2` held, such as a version with a household-only country. Code +released before cache releases writes only `active`, which current readers do +not read while `active-with-cache` exists; a rollback that far needs the newer +keys deleted from the Modal Dict first. Removing the older copies needs a later +change, once no deployed or rollback revision reads them. A rollback to code released before region datasets has one window: the older worker is deployed before its publish job runs, and until that publish the @@ -430,8 +460,9 @@ region dataset copy still matches, so updated entry services keep sending regional provenance for state and district runs, which the older worker refuses. Only Stage 12 comparison runs fail in that window, never production responses; if the older deploy's validation fails, the window lasts until a later publish. -To avoid it, delete the `active-with-region-datasets` key from the -`simulation-api-v2-version-manifest` Modal Dict before deploying the older code. +To avoid it, delete the `active-schema-2` and `active-with-region-datasets` keys +from the `simulation-api-v2-version-manifest` Modal Dict before deploying the +older code. `region_datasets` names the dataset each region type loads. The entry service records that dataset as a state or district run's dataset provenance, and the @@ -447,6 +478,51 @@ not satisfy the requested output plan is recalculated. Each region-group log records whether it loaded a complete file, recalculated an incomplete file, or computed because no file existed. +## v2 household functions + +The versioned v2 application also defines a household function for each +country, `calculate_household_us` and `calculate_household_uk`. Each call +calculates one household, in the Public API's situation shape and with +optional axes, under one to ten policies, so a baseline and its reform are +calculated in one call. It takes a `HouseholdExecutionRequest` and returns a +`HouseholdExecutionResult` or a typed `HouseholdExecutionFailure` from +`policyengine_simulation_contract.household_execution`, as JSON. The request +names the run's year, such as a stored household's default year. A +calculate-full request adds the household's missing variables for that year, +and an axis or a variable value given without a period is for that year too; +every other value is calculated for the period the household names. +The Public API instead takes the year for added variables from a person named +`you`, or the current year (v2 households name people by record ID), and +leaves values and axes without a period to the country model's own default +year. Otherwise the +calculation follows the Public API's household calculation, including its +input errors, warnings and SPM receipt. A function refuses a request for +another country, and returns a `bundle_mismatch` failure for a request that +names another bundle. Nothing calls these functions until the Simulation +Entrypoint has household routes. + +- Image: the CA store, the frozen `modal-simulation-image` dependency group + and the source packages, without the bundle's datasets or the Stage 12 + cache. Building it needs no secret. +- Secrets: none. A household calculation reads no private data, Cloud + Storage or database. Telemetry authenticates with the identity token Modal + gives every container (`MODAL_IDENTITY_TOKEN`) and the workload identity + settings baked into the image at deployment. +- Limits: 2 CPUs, 4,096 MiB of reserved and 16,384 MiB of maximum memory, a + 900-second timeout, no retries, and a Modal `max_containers` value of 20. +- Each container loads a country model the first time it calculates that + country and keeps it for later calls. + +Deployment validation calls each household function with a one-adult, +current-law household and requires a successful calculation with the deployed +bundle, reported by that function, with a number for the household's net +income. The validation record keeps each invocation ID, and the manifest +publisher records a country's `household_callable` only when the record shows +its function was validated. A failed household validation fails v2 worker +validation like any other validation failure: the manifest is not published, +and, as described under "Release and deployment constraints", the deployment +does not promote the new Simulation Entrypoint revision. + ## Temporary direct runner endpoint > **Temporary Stage 12 interface:** The authenticated routes in this section diff --git a/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/household_execution.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py new file mode 100644 index 000000000..90725b033 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/household_execution.py @@ -0,0 +1,678 @@ +"""Versioned household calculation contract for the v2 Modal household functions. + +A household travels in the situation shape the Public API's household +calculation routes accept and return: ``people`` maps each person to their +variables, every other top-level key except ``axes`` is a group entity +collection (such as ``households`` or ``tax_units``) whose groups list their +``members``, each variable maps periods to values, and an optional ``axes`` +list varies inputs across points. A calculated household keeps that shape, +with each requested value filled in, so a v2 result can be compared field by +field with the Public API's in-process calculation. + +These models check structure, types and size only. Whether an entity +collection, variable or policy parameter exists is decided by the country +model inside the household function, which reports unknown names with the +same input errors as the Public API. + +Wire models: + +- ``HouseholdExecutionRequest``: the caller to a Modal household function. It + names one to ten policies, so a baseline and its reform are calculated in + one call. +- ``HouseholdExecutionResult`` or ``HouseholdExecutionFailure``: what the + household function returns, told apart by ``status``. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from typing import TYPE_CHECKING, Annotated, Any, Literal, Self +from uuid import UUID + +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + JsonValue, + PrivateAttr, + TypeAdapter, + field_validator, + model_serializer, + model_validator, +) + +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMProvenance, + SPMResolvedConfiguration, +) +from policyengine_simulation_contract.stage12_bundle import ( + CountryId, + Sha256Digest, + Stage12BundleManifest, +) + +if TYPE_CHECKING: + from pydantic import ModelWrapValidatorHandler, SerializerFunctionWrapHandler + +HOUSEHOLD_CONTRACT_SCHEMA_VERSION = 1 +# A v2 worker application's household function for a country is this prefix +# followed by the country ID, such as ``calculate_household_us``. +HOUSEHOLD_CALLABLE_PREFIX = "calculate_household_" +# Size limits. The variable limit leaves room for a complete calculated +# household sent back as input, which holds every variable of each entity. +MAXIMUM_PEOPLE = 1_000 +MAXIMUM_GROUPS_PER_COLLECTION = 1_000 +MAXIMUM_GROUP_COLLECTIONS = 32 +MAXIMUM_VARIABLES_PER_ENTITY = 10_000 +MAXIMUM_PERIODS_PER_VARIABLE = 1_000 +MAXIMUM_AXIS_GROUPS = 8 +MAXIMUM_PARALLEL_AXES = 32 +# The number of points a household is calculated at: the product of the point +# counts of its perpendicular axis groups. +MAXIMUM_AXIS_POINTS = 10_000 +MAXIMUM_POLICIES_PER_REQUEST = 10 +MAXIMUM_POLICY_PARAMETERS = 10_000 +MAXIMUM_WARNINGS = 10_000 + +EntityKey = Annotated[str, Field(min_length=1, max_length=255)] +EntityPlural = Annotated[str, Field(min_length=1, max_length=255)] +VariableName = Annotated[str, Field(min_length=1, max_length=255)] +PeriodKey = Annotated[str, Field(min_length=1, max_length=64)] +ParameterPath = Annotated[str, Field(min_length=1, max_length=512)] +ParameterPeriod = Annotated[str, Field(min_length=1, max_length=64)] +ContractText = Annotated[str, Field(min_length=1, max_length=255)] +CalculationYear = Annotated[ + str, + Field( + pattern=r"^[0-9]{4}$", + description=( + "The run's year, written as a situation's period key, such as " + "'2025': the period of added variables and of values and axes " + "the household gives without one." + ), + ), +] +PolicyKey = Annotated[ + str, + Field( + pattern=r"^[a-z][a-z0-9_-]{0,63}$", + description=( + "Caller-chosen name for one policy in the request, such as " + "'baseline' or 'reform'; its result carries the same name." + ), + ), +] + +# One input value. ``None`` asks the household function to calculate it. +InputScalar = bool | int | float | str | None +PeriodInputs = Annotated[ + dict[PeriodKey, InputScalar], + Field(max_length=MAXIMUM_PERIODS_PER_VARIABLE), +] +# A variable's values by period, or one value, for the request's year. +VariableInput = PeriodInputs | InputScalar +EntityInputs = Annotated[ + dict[VariableName, VariableInput], + Field(max_length=MAXIMUM_VARIABLES_PER_ENTITY), +] +# One calculated value. A household with axes holds one value per point. +# Infinite and undefined numbers are the strings "Infinity", "-Infinity" and +# "NaN"; the Public API writes infinities the same way. +OutputScalar = bool | int | float | str | None +OutputValue = OutputScalar | list[OutputScalar] +PeriodOutputs = Annotated[ + dict[PeriodKey, OutputValue], + Field(max_length=MAXIMUM_PERIODS_PER_VARIABLE), +] +VariableOutput = PeriodOutputs | OutputScalar +EntityOutputs = Annotated[ + dict[VariableName, VariableOutput], + Field(max_length=MAXIMUM_VARIABLES_PER_ENTITY), +] +ParameterScalar = bool | int | float | str | None +# A list value sets a parameter whose value is a list, such as a list of +# variable names. +ParameterValue = ParameterScalar | list[ParameterScalar] + +HouseholdErrorCode = Literal[ + # A household entity collection, variable, axis variable or policy + # parameter the country model does not have. ``input_errors`` lists them. + "invalid_inputs", + # The country model could not build a simulation from the household. + "invalid_household", + # The SPM selection, or the SPM configuration it needs, is unusable. + # ``spm_error`` carries the SPM error code. + "invalid_spm_selection", + # The request names a bundle other than the one this function runs. + "bundle_mismatch", + # Any other failure while calculating. + "calculation_failed", +] +InputErrorType = Literal[ + "household_entity", + "household_variable", + "household_variable_wrong_entity", + "household_axis_variable", + "policy_parameter", +] + + +class StrictHouseholdModel(BaseModel): + """Base for household contracts; rejects unknown fields and non-finite numbers.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, frozen=True) + + +class CallerOrderedModel(BaseModel): + """A model that serializes its top-level keys in the order its caller gave them. + + The Public API walks a household in the order the caller sent it, so the + order of unrecognized inputs, warnings and calculated values depends on + it. Pydantic itself would write the declared fields before the extra ones. + Keys the caller did not give follow, in Pydantic's order. + """ + + model_config = ConfigDict(extra="allow", allow_inf_nan=False, frozen=True) + + _caller_key_order: tuple[str, ...] = PrivateAttr(default=()) + + @model_validator(mode="wrap") + @classmethod + def remember_caller_key_order( + cls, data: object, handler: ModelWrapValidatorHandler[Self] + ) -> Self: + """Record the order of the caller's top-level keys for serialization.""" + validated: Self = handler(data) + if isinstance(data, Mapping): + validated._caller_key_order = tuple(str(key) for key in data) + elif isinstance(data, CallerOrderedModel): + validated._caller_key_order = data.caller_key_order + return validated + + @model_serializer(mode="wrap") + def serialize_in_caller_order( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Return the caller's keys in the caller's order, then any others.""" + dumped: dict[str, Any] = handler(self) + ordered = {key: dumped[key] for key in self._caller_key_order if key in dumped} + ordered.update(dumped) + left_out = self.keys_left_out() + return {key: value for key, value in ordered.items() if key not in left_out} + + @property + def caller_key_order(self) -> tuple[str, ...]: + """The caller's top-level keys, in the order the caller gave them.""" + return self._caller_key_order + + def keys_left_out(self) -> frozenset[str]: + """Return the keys serialization leaves out; none unless a subclass says.""" + return frozenset() + + +def _require_unique_members(members: tuple[str, ...]) -> tuple[str, ...]: + if len(set(members)) != len(members): + msg = "group members must be unique" + raise ValueError(msg) + return members + + +GroupMembers = Annotated[ + tuple[EntityKey, ...], + Field(min_length=1, max_length=MAXIMUM_PEOPLE), + AfterValidator(_require_unique_members), +] + + +class HouseholdGroup(CallerOrderedModel): + """One group entity instance, such as a household or a tax unit. + + ``members`` names the people in the group. Every other key is a variable + mapped to its values by period, exactly as in the Public API's situation + shape. + """ + + members: GroupMembers + __pydantic_extra__: dict[VariableName, VariableInput] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + @model_validator(mode="after") + def limit_variables(self) -> Self: + """Bound the number of variables one group may carry.""" + if len(self.model_extra or {}) > MAXIMUM_VARIABLES_PER_ENTITY: + msg = "a group carries too many variables" + raise ValueError(msg) + return self + + +class CalculatedHouseholdGroup(CallerOrderedModel): + """One calculated group entity instance: its members and calculated variables.""" + + members: GroupMembers + __pydantic_extra__: dict[VariableName, VariableOutput] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + @model_validator(mode="after") + def limit_variables(self) -> Self: + """Bound the number of variables one group may carry.""" + if len(self.model_extra or {}) > MAXIMUM_VARIABLES_PER_ENTITY: + msg = "a group carries too many variables" + raise ValueError(msg) + return self + + +class HouseholdAxis(StrictHouseholdModel): + """One input varied across points, as policyengine-core reads an axis. + + The variable ``name`` takes ``count`` evenly spaced values from ``min`` to + ``max`` in ``period`` (the request's year when omitted), for the + entity member at position ``index`` (the first when omitted). + """ + + name: VariableName + count: Annotated[int, Field(ge=1, le=MAXIMUM_AXIS_POINTS)] + min: int | float + max: int | float + period: PeriodKey | int | None = None + index: Annotated[int, Field(ge=0)] | None = None + + @model_serializer(mode="wrap") + def serialize_axis(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + """Leave out the optional keys the caller did not give.""" + dumped: dict[str, Any] = handler(self) + return { + name: value + for name, value in dumped.items() + if name not in {"period", "index"} or name in self.model_fields_set + } + + +AxisGroup = Annotated[ + tuple[HouseholdAxis, ...], + Field(min_length=1, max_length=MAXIMUM_PARALLEL_AXES), +] + + +def _axis_point_count(axes: tuple[tuple[HouseholdAxis, ...], ...] | None) -> int: + if not axes: + return 1 + return math.prod(group[0].count for group in axes) + + +def _validated_axes( + axes: tuple[tuple[HouseholdAxis, ...], ...] | None, +) -> tuple[tuple[HouseholdAxis, ...], ...] | None: + if axes is None: + return None + for group in axes: + if len({axis.count for axis in group}) != 1: + msg = "parallel axes in one group must share one point count" + raise ValueError(msg) + if _axis_point_count(axes) > MAXIMUM_AXIS_POINTS: + msg = ( + "a household may be calculated at no more than " + f"{MAXIMUM_AXIS_POINTS} axis points" + ) + raise ValueError(msg) + return axes + + +Axes = Annotated[ + tuple[AxisGroup, ...] | None, + Field(max_length=MAXIMUM_AXIS_GROUPS), + AfterValidator(_validated_axes), +] + + +# One group entity collection: each group of one entity, by the group's name. +InputGroups = Annotated[ + dict[EntityKey, HouseholdGroup], Field(max_length=MAXIMUM_GROUPS_PER_COLLECTION) +] +CalculatedGroups = Annotated[ + dict[EntityKey, CalculatedHouseholdGroup], + Field(max_length=MAXIMUM_GROUPS_PER_COLLECTION), +] + + +class _Situation(CallerOrderedModel): + """A situation-shaped household: ``people``, group collections and ``axes``.""" + + axes: Axes = None + + @model_validator(mode="after") + def limit_collections(self) -> Self: + """Bound the number of group collections a household may carry.""" + if len(self.model_extra or {}) > MAXIMUM_GROUP_COLLECTIONS: + msg = "a household carries too many group collections" + raise ValueError(msg) + return self + + def keys_left_out(self) -> frozenset[str]: + """Leave out ``axes`` when the household has none.""" + return frozenset({"axes"}) if self.axes is None else frozenset() + + @property + def has_axes(self) -> bool: + """Whether the household is calculated at more than one set of inputs.""" + return self.axes is not None + + def axis_point_count(self) -> int: + """Return how many points the household is calculated at (1 without axes).""" + return _axis_point_count(self.axes) + + def to_situation(self) -> dict[str, Any]: + """Return the household as the JSON situation a country model reads.""" + return self.model_dump(mode="json") + + +class HouseholdDocument(_Situation): + """A household to calculate, in the Public API's situation shape. + + ``people`` maps each person's name to their variables. Every other key + except ``axes`` is a group entity collection, by its plural, mapping each + group's name to a ``HouseholdGroup``. A variable maps periods (such as + ``"2026"``) to values; ``None`` asks for the value to be calculated. + """ + + people: Annotated[ + dict[EntityKey, EntityInputs], Field(min_length=1, max_length=MAXIMUM_PEOPLE) + ] + __pydantic_extra__: dict[EntityPlural, InputGroups] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + +class CalculatedHousehold(_Situation): + """A calculated household, in the shape the Public API returns as ``result``. + + It holds the request's people, groups and axes with each requested value + filled in. With axes, a calculated variable holds one value per point. + """ + + people: Annotated[ + dict[EntityKey, EntityOutputs], Field(min_length=1, max_length=MAXIMUM_PEOPLE) + ] + __pydantic_extra__: dict[EntityPlural, CalculatedGroups] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] # Pydantic's documented way to type extra fields + + +class HouseholdPolicy(StrictHouseholdModel): + """One policy to calculate the household under. + + ``parameters`` maps each changed parameter path to its values by period + range (``"YYYY-MM-DD.YYYY-MM-DD"``), as in the Public API's policy JSON. + Empty parameters mean current law. + """ + + policy_key: PolicyKey + parameters: Annotated[ + dict[ParameterPath, dict[ParameterPeriod, ParameterValue]], + Field(max_length=MAXIMUM_POLICY_PARAMETERS), + ] + + +def _unique_policy_keys( + policies: tuple[HouseholdPolicy, ...], +) -> tuple[HouseholdPolicy, ...]: + keys = [policy.policy_key for policy in policies] + if len(set(keys)) != len(keys): + msg = "policy keys must be unique within a request" + raise ValueError(msg) + return policies + + +Policies = Annotated[ + tuple[HouseholdPolicy, ...], + Field(min_length=1, max_length=MAXIMUM_POLICIES_PER_REQUEST), + AfterValidator(_unique_policy_keys), +] + + +class HouseholdBundleProvenance(StrictHouseholdModel): + """The PolicyEngine.py bundle a household calculation runs with. + + Names the PolicyEngine.py and policyengine-core versions, the country model + package and version, and the SHA-256 of the normalized bundle manifest. + """ + + policyengine_version: ContractText + core_package_version: ContractText + country_package_name: ContractText + country_package_version: ContractText + bundle_manifest_sha256: Sha256Digest + + @classmethod + def from_bundle( + cls, + bundle: Stage12BundleManifest, + *, + bundle_manifest_sha256: str, + country: str, + ) -> Self: + """Return ``country``'s provenance within a normalized bundle manifest. + + Raises ValueError when the bundle does not carry ``country``. + """ + country_bundle = next( + (item for item in bundle.countries if item.country == country), None + ) + if country_bundle is None: + msg = f"the bundle does not carry country {country!r}" + raise ValueError(msg) + return cls( + policyengine_version=bundle.policyengine_version, + core_package_version=bundle.core_package_version, + country_package_name=country_bundle.country_package_name, + country_package_version=country_bundle.country_package_version, + bundle_manifest_sha256=bundle_manifest_sha256, + ) + + +def household_callable_name(country: str) -> str: + """Return the name of a v2 worker application's household function.""" + return f"{HOUSEHOLD_CALLABLE_PREFIX}{country}" + + +class HouseholdExecutionRequest(StrictHouseholdModel): + """A caller to a Modal household function: calculate one household. + + ``calculation_id`` is the caller's durable identity for the calculation. + ``year`` is the run's year, such as a stored household's default year, + written as a situation's period key (four digits): the period of the + variables ``add_missing_variables`` adds and of any value or axis the + household gives without a period. Every other value is calculated for the + period the household names. ``bundle`` is the bundle the caller resolved + from the version manifest; the household function refuses a request for + any other bundle. + + ``add_missing_variables`` adds every yearly, monthly and eternal variable + the household's entities lack, for ``year``, before calculating, as the + Public API's ``/calculate-full`` route does (that route takes its year from + the first age period of a person named ``you``, or else the current year, + and v2 households name people by record ID). ``spm`` carries the caller's + SPM settings exactly as sent, as any JSON value, and the household + function checks them with the Public API's SPM error codes: settings for a + country other than the US are unsupported, and for the US an explicit null + or malformed settings are invalid. Omit ``spm`` to inherit the bundle's + certified defaults; sending it, even as an empty object, is a choice, and + then a missing SPM input is an error rather than an uncalculated value. + ``spm_selection_source`` says whether the settings came from this request + or from a saved household, which changes only how an SPM forecast the + bundle no longer carries is reported. + + To calculate a saved household with the inputs the Public API's + stored-household route (``GET /{country}/household/{id}/policy/{policy_id}``) + uses, set ``add_missing_variables``, because that route always adds + missing variables, and ``year`` to the year the run calculates, such as + the household's default year; pass the household's saved SPM selection as + ``spm`` only if it has one (a saved null is no choice); and set + ``spm_selection_source`` to ``"saved_household"``. When ``year`` is the + year that route would infer and the household gives every value and axis + a period, the calculated household, warnings, SPM receipt and SPM error + codes then match that route's (the executor's household calculation module + lists every difference). Its error messages do not: the route reports a + household it + cannot read, and any other failure except an SPM error, as an error + calculating that household ID under that policy ID, while a household + function reports the request routes' wording (``invalid_household`` and + ``calculation_failed``), so a caller that serves the stored-household + route rewrites those two messages. + + These models check the request's structure; a request they reject (for + example a group without ``members``) is the caller's to report. + """ + + schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION + calculation_id: UUID + country: CountryId + year: CalculationYear + household: HouseholdDocument + policies: Policies + add_missing_variables: bool = False + spm: JsonValue = None + spm_selection_source: Literal["request", "saved_household"] = "request" + bundle: HouseholdBundleProvenance + + @model_serializer(mode="wrap") + def serialize_request( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Leave out ``spm`` when the caller sent none, so omission is kept.""" + dumped: dict[str, Any] = handler(self) + if not self.spm_requested: + dumped.pop("spm", None) + return dumped + + @property + def spm_requested(self) -> bool: + """Whether the caller sent SPM settings rather than inheriting the defaults.""" + return "spm" in self.model_fields_set + + +class HouseholdPolicyResult(StrictHouseholdModel): + """The calculated household under one policy. + + ``warnings`` lists, in order, the deprecated inputs that were ignored and, + for a household with axes, the values that could not be calculated, worded + as the Public API words them. ``spm_provenance`` is the SPM calculation + receipt of a US household calculated with SPM settings. + """ + + policy_key: PolicyKey + household: CalculatedHousehold + warnings: Annotated[tuple[str, ...], Field(max_length=MAXIMUM_WARNINGS)] = () + spm_provenance: SPMProvenance | None = None + + +class HouseholdCalculationOutput(StrictHouseholdModel): + """A finished household calculation: one result per policy, in request order. + + ``spm_config`` is the complete SPM selection the calculation applied: the + caller's choice merged over the bundle's certified defaults, or None when + no SPM applies (any country but the US). + """ + + country: CountryId + bundle: HouseholdBundleProvenance + spm_config: SPMResolvedConfiguration | None = None + results: Annotated[ + tuple[HouseholdPolicyResult, ...], + Field(min_length=1, max_length=MAXIMUM_POLICIES_PER_REQUEST), + ] + + @field_validator("results") + @classmethod + def require_unique_policy_keys( + cls, results: tuple[HouseholdPolicyResult, ...] + ) -> tuple[HouseholdPolicyResult, ...]: + """Reject results that repeat a policy key.""" + keys = [result.policy_key for result in results] + if len(set(keys)) != len(keys): + msg = "result policy keys must be unique" + raise ValueError(msg) + return results + + +class HouseholdInputError(StrictHouseholdModel): + """One household or policy name the country model does not recognize. + + Field names and messages match the entries of the Public API's ``errors`` + list, which name entity plurals only for a variable placed on the wrong + entity. + """ + + type: InputErrorType + name: str + path: str + message: str + expected_entity_plural: str | None = None + actual_entity_plural: str | None = None + + @model_serializer(mode="wrap") + def serialize_input_error( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Leave out unset entity plurals, as the Public API's error entries do.""" + dumped: dict[str, Any] = handler(self) + for name in ("expected_entity_plural", "actual_entity_plural"): + if dumped.get(name) is None: + dumped.pop(name, None) + return dumped + + +class HouseholdCalculationError(StrictHouseholdModel): + """Why a household calculation failed. + + ``message`` is worded as the Public API words the matching error response. + ``input_errors`` lists unrecognized names for ``invalid_inputs``; + ``spm_error`` holds the SPM error code for ``invalid_spm_selection``; + ``policy_key`` names the policy being prepared or calculated, when the + failure belongs to one. + """ + + code: HouseholdErrorCode + message: Annotated[str, Field(min_length=1)] + input_errors: tuple[HouseholdInputError, ...] = () + spm_error: SPMErrorDetail | None = None + policy_key: PolicyKey | None = None + + +class HouseholdWorkerIdentity(StrictHouseholdModel): + """The deployed Modal application and function that ran the calculation.""" + + application_name: ContractText + function_name: ContractText + + +class HouseholdExecutionResult(StrictHouseholdModel): + """A Modal household function to its caller: the calculation succeeded.""" + + status: Literal["succeeded"] = "succeeded" + schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION + calculation_id: UUID + output: HouseholdCalculationOutput + worker: HouseholdWorkerIdentity + + +class HouseholdExecutionFailure(StrictHouseholdModel): + """A Modal household function to its caller: the calculation failed.""" + + status: Literal["failed"] = "failed" + schema_version: Literal[1] = HOUSEHOLD_CONTRACT_SCHEMA_VERSION + calculation_id: UUID + error: HouseholdCalculationError + worker: HouseholdWorkerIdentity + + +HouseholdExecutionOutcome = Annotated[ + HouseholdExecutionResult | HouseholdExecutionFailure, + Field(discriminator="status"), +] +_EXECUTION_OUTCOME_ADAPTER: TypeAdapter[ + HouseholdExecutionResult | HouseholdExecutionFailure +] = TypeAdapter(HouseholdExecutionOutcome) + + +def parse_household_execution_outcome( + payload: object, +) -> HouseholdExecutionResult | HouseholdExecutionFailure: + """Validate a household function's return value as a success or a failure.""" + return _EXECUTION_OUTCOME_ADAPTER.validate_python(payload) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py index a10d414cc..1f935a9f1 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/stage12_manifest.py @@ -1,4 +1,12 @@ -"""Strict, separately stored Stage 12 v2 worker version manifest.""" +"""Strict, separately stored Stage 12 v2 worker version manifest. + +Schema version 2 records, for each country a worker application serves, +whether it can run economy reports and which function calculates its +households. It is stored under its own key. Whenever schema version 1 can +describe the same manifest, it is also written in that form, without those +fields, under the keys that readers released before schema version 2 read: +with region datasets, without them, and without them or cache releases. +""" from __future__ import annotations @@ -8,7 +16,7 @@ from copy import deepcopy from datetime import datetime from hashlib import sha256 -from typing import Annotated, Literal, Protocol +from typing import TYPE_CHECKING, Annotated, Literal, Protocol from packaging.version import InvalidVersion, Version from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -19,16 +27,24 @@ Stage12BundleManifest, ) +if TYPE_CHECKING: + from pydantic.main import IncEx + V1_ROUTING_STATE_NAME = "simulation-api-routing-state" V2_VERSION_MANIFEST_NAME = "simulation-api-v2-version-manifest" -# The complete manifest, including each country bundle's region datasets. +# The schema version 2 manifest. Readers released with schema version 2 read +# this key first. +SCHEMA_2_MANIFEST_KEY = "active-schema-2" +# Readers released before schema version 2 forbid unknown fields, so they read +# the keys below, which hold the same manifest in schema version 1 form. This +# one keeps each country bundle's region datasets. REGION_DATASETS_MANIFEST_KEY = "active-with-region-datasets" # Readers released before region datasets forbid unknown fields, so they keep -# reading this key, which holds the same manifest without them. +# reading this key, which holds the schema version 1 form without them. ACTIVE_MANIFEST_KEY = "active-with-cache" # Readers released before Stage 12 cache releases forbid unknown fields, so -# they keep reading this key, which holds the same manifest without them or -# region datasets. +# they keep reading this key, which holds the schema version 1 form without +# the cache fields or region datasets. PRE_CACHE_MANIFEST_KEY = "active" V2_APPLICATION_PREFIX = "policyengine-simulation-v2-py" V1_APPLICATION_PREFIX = "policyengine-simulation-py" @@ -46,10 +62,35 @@ class StrictManifestModel(BaseModel): class V2CountryWorker(StrictManifestModel): - """A country served by a v2 worker, and the callable that runs its simulations.""" + """A country served by a v2 worker application, and what it can calculate. + + An economy-capable country names the callable that runs one of its report + simulations; only such a country's reports are given to the report + coordinator. ``household_callable`` names the function that calculates + its households, and is None when the application has none or it was not + validated. + """ country: CountryId - single_simulation_callable: CallableName + economy_capable: bool + single_simulation_callable: CallableName | None = None + household_callable: CallableName | None = None + + @model_validator(mode="after") + def validate_capabilities(self) -> V2CountryWorker: + """Require a simulation callable exactly when the country is economy-capable. + + A country must also have at least one capability. + """ + if self.economy_capable != (self.single_simulation_callable is not None): + msg = ( + "an economy-capable country, and only one, names a simulation callable" + ) + raise ValueError(msg) + if not self.economy_capable and self.household_callable is None: + msg = "a country needs at least one capability" + raise ValueError(msg) + return self class Stage12CacheRelease(StrictManifestModel): @@ -71,7 +112,8 @@ class V2WorkerValidation(StrictManifestModel): """Receipt for a successful validation of a deployed v2 worker application. Names the application and bundle digest that were validated, when, and the - validation invocation IDs overall and for each country. + validation invocation IDs: overall, for each economy-capable country's + simulation worker, and for each country's household function. """ validated: Literal[True] @@ -80,14 +122,36 @@ class V2WorkerValidation(StrictManifestModel): validated_at: datetime validation_invocation_id: ManifestText country_validation_invocation_ids: dict[CountryId, ManifestText] + household_validation_invocation_ids: dict[CountryId, ManifestText] = Field( + default_factory=dict + ) cache: Stage12CacheRelease | None = None +def _require_function_receipts( + countries: tuple[V2CountryWorker, ...], validation: V2WorkerValidation +) -> None: + """Require a validation invocation for exactly the functions countries name.""" + economy_countries = { + worker.country for worker in countries if worker.economy_capable + } + if set(validation.country_validation_invocation_ids) != economy_countries: + msg = "worker validation does not cover exactly the economy-capable countries" + raise ValueError(msg) + household_countries = { + worker.country for worker in countries if worker.household_callable is not None + } + if set(validation.household_validation_invocation_ids) != household_countries: + msg = "worker validation does not cover exactly the household functions" + raise ValueError(msg) + + class V2WorkerVersion(StrictManifestModel): """One deployed v2 worker application and the bundle it runs. Lists its report coordinator callable, its per-country workers and its - validation receipt; the countries must match the bundle's, in order. + validation receipt; the countries must match the bundle's, in order, and + every function a country names must have been validated. """ application_name: ManifestText @@ -105,7 +169,7 @@ def validate_worker(self) -> V2WorkerVersion: The application name must be the v2 name derived from the bundle's PolicyEngine.py version, the validation must name this application and bundle digest, and the countries must equal the bundle's, in order, - each with a validation invocation. + each named function with a validation invocation. """ if not self.application_name.startswith(V2_APPLICATION_PREFIX): msg = "v2 application uses an invalid name prefix" @@ -134,11 +198,45 @@ def validate_worker(self) -> V2WorkerVersion: if countries != bundle_countries: msg = "v2 worker countries do not match the normalized bundle" raise ValueError(msg) - if set(self.validation.country_validation_invocation_ids) != set(countries): - msg = "worker validation does not cover every country" - raise ValueError(msg) + _require_function_receipts(self.countries, self.validation) return self + def economy_worker(self, country: str) -> V2CountryWorker | None: + """Return ``country``'s worker when it can run economy reports.""" + return next( + ( + worker + for worker in self.countries + if worker.country == country and worker.economy_capable + ), + None, + ) + + def economy_simulation_callable(self, country: str) -> str: + """Return the callable that runs one of ``country``'s report simulations. + + Raises LookupError when the manifest does not mark ``country`` as + economy-capable. + """ + worker = self.economy_worker(country) + if worker is None or worker.single_simulation_callable is None: + msg = ( + f"country {country!r} is not economy-capable in {self.application_name}" + ) + raise LookupError(msg) + return worker.single_simulation_callable + + def household_worker(self, country: str) -> V2CountryWorker | None: + """Return ``country``'s worker when it has a household function.""" + return next( + ( + worker + for worker in self.countries + if worker.country == country and worker.household_callable is not None + ), + None, + ) + class V2VersionManifest(StrictManifestModel): """Registry of deployed v2 worker versions, keyed by PolicyEngine.py version. @@ -147,7 +245,7 @@ class V2VersionManifest(StrictManifestModel): ``generation`` identifies the publish that produced this manifest. """ - schema_version: Literal[1] = 1 + schema_version: Literal[2] = 2 generation: ManifestText default_version: ManifestText versions: dict[str, V2WorkerVersion] @@ -224,37 +322,106 @@ def _is_newer(candidate: str, current: str | None) -> bool: return False -_REGION_DATASETS_FIELD = { - "bundle": {"countries": {"__all__": {"region_datasets"}}}, -} +def schema_1_manifest_payload( + manifest: V2VersionManifest, + *, + include_cache: bool = True, + include_region_datasets: bool = True, +) -> dict[str, object] | None: + """Return the manifest as readers released before schema version 2 accept it. + + Without ``include_region_datasets`` each country bundle's region datasets + are left out, for readers released before them; without ``include_cache`` + the cache fields are left out too, for readers released before cache + releases. Returns None when a country is not economy-capable, which + schema version 1 cannot describe. + """ + if any( + not country.economy_capable + for worker in manifest.versions.values() + for country in worker.countries + ): + return None + validation_exclude = {"household_validation_invocation_ids"} + worker_exclude: dict[str, IncEx | bool] = { + "countries": {"__all__": {"economy_capable", "household_callable"}}, + "validation": validation_exclude, + } + if not include_region_datasets: + worker_exclude["bundle"] = {"countries": {"__all__": {"region_datasets"}}} + if not include_cache: + worker_exclude["cache"] = True + validation_exclude.add("cache") + payload = manifest.model_dump( + mode="json", + exclude={"versions": {"__all__": worker_exclude}}, + ) + payload["schema_version"] = 1 + return payload def pre_region_datasets_manifest_payload( manifest: V2VersionManifest, -) -> dict[str, object]: +) -> dict[str, object] | None: """Return the manifest as readers released before region datasets accept it.""" - return manifest.model_dump( - mode="json", - exclude={"versions": {"__all__": _REGION_DATASETS_FIELD}}, - ) + return schema_1_manifest_payload(manifest, include_region_datasets=False) -def pre_cache_manifest_payload(manifest: V2VersionManifest) -> dict[str, object]: +def pre_cache_manifest_payload(manifest: V2VersionManifest) -> dict[str, object] | None: """Return the manifest as readers released before cache releases accept it.""" - return manifest.model_dump( - mode="json", - exclude={ - "versions": { - "__all__": { - "cache": True, - "validation": {"cache"}, - **_REGION_DATASETS_FIELD, - } - } - }, + return schema_1_manifest_payload( + manifest, include_cache=False, include_region_datasets=False ) +def _schema_1_copies( + manifest: V2VersionManifest, +) -> dict[str, dict[str, object]] | None: + """Return the schema version 1 documents a publish writes, by key. + + Returns None when schema version 1 cannot describe the manifest. + """ + complete = schema_1_manifest_payload(manifest) + without_region_datasets = pre_region_datasets_manifest_payload(manifest) + without_cache = pre_cache_manifest_payload(manifest) + if complete is None or without_region_datasets is None or without_cache is None: + return None + return { + REGION_DATASETS_MANIFEST_KEY: complete, + ACTIVE_MANIFEST_KEY: without_region_datasets, + PRE_CACHE_MANIFEST_KEY: without_cache, + } + + +def _upgraded_schema_1_document(document: Mapping[str, object]) -> dict[str, object]: + """Return a schema version 1 document in schema version 2 form. + + Schema version 1 listed only countries whose report simulations a worker + runs, and no household functions, so every country is economy-capable. + """ + upgraded = deepcopy(dict(document)) + upgraded["schema_version"] = 2 + versions = upgraded.get("versions") + if isinstance(versions, dict): + for worker in versions.values(): + countries = worker.get("countries") if isinstance(worker, dict) else None + if isinstance(countries, list): + for country in countries: + if isinstance(country, dict): + country.setdefault("economy_capable", True) + return upgraded + + +def parse_v2_manifest(value: object) -> V2VersionManifest: + """Validate a stored manifest, reading a schema version 1 document as version 2. + + Raises ValueError (as a pydantic ValidationError) for an invalid document. + """ + if isinstance(value, Mapping) and value.get("schema_version") == 1: + value = _upgraded_schema_1_document(value) + return V2VersionManifest.model_validate(value) + + def _is_current_region_dataset_copy(value: object, cache_copy: object) -> bool: """Return whether ``value`` is the copy the last publish wrote with ``cache_copy``. @@ -268,13 +435,14 @@ def _is_current_region_dataset_copy(value: object, cache_copy: object) -> bool: if cache_copy is None: return True try: - manifest = V2VersionManifest.model_validate(value) + manifest = parse_v2_manifest(value) except (TypeError, ValueError): return False return pre_region_datasets_manifest_payload(manifest) == cache_copy -def _current_manifest(store: V2ManifestStore) -> object: +def _current_schema_1_manifest(store: V2ManifestStore) -> object: + """Return the schema version 1 document the last publish wrote.""" # The newest key the last publish wrote: each older key holds the same # manifest without the fields its readers do not accept. cache_copy = store.get(ACTIVE_MANIFEST_KEY) @@ -287,6 +455,53 @@ def _current_manifest(store: V2ManifestStore) -> object: return store.get(PRE_CACHE_MANIFEST_KEY) +def _newest_validation(manifest: V2VersionManifest) -> datetime: + return max(worker.validation.validated_at for worker in manifest.versions.values()) + + +def _is_current_schema_2_document(store: V2ManifestStore, document: object) -> bool: + """Return whether ``document``, under the schema version 2 key, is the latest. + + Code released before schema version 2 publishes only the older keys, for + example after a rollback, which leaves an out-of-date schema version 2 + document behind. Whenever schema version 1 can describe a manifest, its + publish writes the schema version 1 forms to the older keys, so the + document is current while each older key holds its form. Otherwise (a + country that is not economy-capable) the publish left the older keys + alone, and the document is current unless the document under them was + validated later. An unreadable document counts as current, so readers and + publishers report it. + """ + try: + manifest = parse_v2_manifest(document) + except (TypeError, ValueError): + return True + schema_1_copies = _schema_1_copies(manifest) + if schema_1_copies is not None: + return all( + store.get(key) in (None, payload) + for key, payload in schema_1_copies.items() + ) + older = _current_schema_1_manifest(store) + if older is None: + return True + try: + older_manifest = parse_v2_manifest(older) + except (TypeError, ValueError): + return True + return _newest_validation(older_manifest) <= _newest_validation(manifest) + + +def _current_manifest(store: V2ManifestStore) -> object: + # Until the first schema version 2 publish, only the older keys exist. + schema_2_document = store.get(SCHEMA_2_MANIFEST_KEY) + if schema_2_document is not None and _is_current_schema_2_document( + store, schema_2_document + ): + return schema_2_document + return _current_schema_1_manifest(store) + + def build_next_v2_manifest( *, current: object, @@ -303,7 +518,7 @@ def build_next_v2_manifest( versions: dict[str, V2WorkerVersion] = {} previous_default = None else: - parsed = V2VersionManifest.model_validate(current) + parsed = parse_v2_manifest(current) versions = deepcopy(parsed.versions) previous_default = parsed.default_version version = worker.bundle.policyengine_version @@ -330,8 +545,10 @@ def publish_v2_manifest( ) -> V2VersionManifest: """Add ``worker`` to the stored manifest, write it back and return it. - The whole manifest is written as one store value, so readers never see a - partial update. + The whole manifest is written as one store value per key, so readers never + see a partial update. The schema version 1 keys are rewritten only when + schema version 1 can describe the manifest; otherwise their readers keep + the last manifest written there. """ next_manifest = build_next_v2_manifest( current=_current_manifest(store), @@ -341,9 +558,11 @@ def publish_v2_manifest( # Each key holds one entire validated document, and a reader uses one # whole document (the region dataset copy only while it matches the cache # copy), so no reader observes a partial update assembled from several. - store[REGION_DATASETS_MANIFEST_KEY] = next_manifest.model_dump(mode="json") - store[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(next_manifest) - store[PRE_CACHE_MANIFEST_KEY] = pre_cache_manifest_payload(next_manifest) + store[SCHEMA_2_MANIFEST_KEY] = next_manifest.model_dump(mode="json") + schema_1_copies = _schema_1_copies(next_manifest) + if schema_1_copies is not None: + for key, payload in schema_1_copies.items(): + store[key] = payload return next_manifest @@ -363,7 +582,7 @@ def load(self) -> V2VersionManifest: """ value = _current_manifest(self._store) try: - manifest = V2VersionManifest.model_validate(value) + manifest = parse_v2_manifest(value) except (TypeError, ValueError): if self._last_valid is None: msg = "no valid v2 version manifest is available" diff --git a/libs/policyengine-simulation-contract/tests/test_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_household_execution.py b/libs/policyengine-simulation-contract/tests/test_household_execution.py new file mode 100644 index 000000000..ef407588b --- /dev/null +++ b/libs/policyengine-simulation-contract/tests/test_household_execution.py @@ -0,0 +1,575 @@ +"""Tests for the versioned household calculation contract.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import pytest +from policyengine_simulation_contract.household_execution import ( + MAXIMUM_AXIS_POINTS, + MAXIMUM_POLICIES_PER_REQUEST, + CalculatedHousehold, + HouseholdBundleProvenance, + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdDocument, + HouseholdExecutionFailure, + HouseholdExecutionRequest, + HouseholdExecutionResult, + HouseholdInputError, + HouseholdPolicy, + HouseholdPolicyResult, + HouseholdWorkerIdentity, + household_callable_name, + parse_household_execution_outcome, +) +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMResolvedConfiguration, + SPMRuntimeVersions, + SPMSelection, + build_spm_provenance, +) +from policyengine_simulation_contract.stage12_bundle import ( + Stage12BundleManifest, + Stage12CountryBundle, + Stage12Dataset, +) +from pydantic import ValidationError + +CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") + +# A household in the situation shape the Public API's calculate routes accept. +US_SITUATION = { + "people": { + "you": {"age": {"2026": 40}, "employment_income": {"2026": 50_000}}, + "your partner": {"age": {"2026": 38}}, + }, + "households": { + "your household": { + "members": ["you", "your partner"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + } + }, + "tax_units": {"your tax unit": {"members": ["you", "your partner"]}}, + "axes": [ + [ + { + "name": "employment_income", + "count": 11, + "min": 0, + "max": 100_000, + "period": "2026", + } + ] + ], +} +# The same household with its top-level keys, and one group's keys, in +# another order. +REORDERED_SITUATION = { + "households": { + "your household": { + "state_name": {"2026": "CA"}, + "members": ["you", "your partner"], + "household_net_income": {"2026": None}, + } + }, + "axes": US_SITUATION["axes"], + "people": US_SITUATION["people"], + "tax_units": US_SITUATION["tax_units"], +} +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 3_000} +} +SPM_RUNTIME_VERSIONS = SPMRuntimeVersions.model_validate( + { + "policyengine": "0.0.0-test-policyengine", + "policyengine-core": "0.0.0-test-policyengine-core", + "policyengine-us": "0.0.0-test-policyengine-us", + "spm-calculator": "0.0.0-test-spm-calculator", + } +) +RESOLVED_SPM_SELECTION = SPMSelection( + forecast_content_sha256="c" * 64, + scenario="ce_trend", + geography_kind="national", + geography_id=None, + county_vintage="2020", + as_of=None, +) + + +def _bundle() -> HouseholdBundleProvenance: + return HouseholdBundleProvenance( + policyengine_version="6.7.5", + core_package_version="3.33.0", + country_package_name="policyengine-us", + country_package_version="2.2.1", + bundle_manifest_sha256="a" * 64, + ) + + +def _policies() -> list[dict[str, object]]: + return [ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": CTC_REFORM}, + ] + + +def _request(**overrides: object) -> HouseholdExecutionRequest: + values: dict[str, object] = { + "calculation_id": str(CALCULATION_ID), + "country": "us", + "year": "2026", + "household": US_SITUATION, + "policies": _policies(), + "bundle": _bundle().model_dump(mode="json"), + } + values.update(overrides) + return HouseholdExecutionRequest.model_validate(values) + + +def _worker() -> HouseholdWorkerIdentity: + return HouseholdWorkerIdentity( + application_name="policyengine-simulation-v2-py6-7-5", + function_name="calculate_household_us", + ) + + +def _calculated(situation: dict) -> CalculatedHousehold: + return CalculatedHousehold.model_validate(situation) + + +def _output(**overrides: object) -> HouseholdCalculationOutput: + values: dict[str, object] = { + "country": "us", + "bundle": _bundle(), + "results": ( + HouseholdPolicyResult( + policy_key="baseline", + household=_calculated( + { + "people": {"you": {"age": {"2026": 40}}}, + "households": {"your household": {"members": ["you"]}}, + } + ), + warnings=("Input `x` was ignored.",), + ), + ), + } + values.update(overrides) + return HouseholdCalculationOutput.model_validate(values) + + +def test_household_document_keeps_the_public_api_situation_shape() -> None: + document = HouseholdDocument.model_validate(US_SITUATION) + + assert document.to_situation() == US_SITUATION + assert document.has_axes + assert document.axis_point_count() == 11 + + +def test_households_keep_the_callers_key_order_through_transport() -> None: + # The Public API walks a household in the order its caller sent it, so the + # order of unrecognized inputs, warnings and calculated values follows it. + request = _request(household=REORDERED_SITUATION) + transported = HouseholdExecutionRequest.model_validate( + json.loads(request.model_dump_json()) + ) + + for situation in ( + request.household.to_situation(), + transported.household.to_situation(), + json.loads(transported.household.model_dump_json()), + _calculated(REORDERED_SITUATION).to_situation(), + ): + assert list(situation) == list(REORDERED_SITUATION) + assert list(situation["households"]["your household"]) == [ + "state_name", + "members", + "household_net_income", + ] + assert situation == REORDERED_SITUATION + + +def test_a_household_without_axes_never_gains_an_axes_key() -> None: + situation = {key: value for key, value in US_SITUATION.items() if key != "axes"} + + document = HouseholdDocument.model_validate(situation) + + assert not document.has_axes + assert document.axis_point_count() == 1 + assert document.to_situation() == situation + assert "axes" not in json.loads(document.model_dump_json()) + + +def test_axes_keep_the_values_and_keys_the_caller_sent() -> None: + situation = { + "people": {"you": {}}, + "axes": [[{"name": "employment_income", "count": 3, "min": 0, "max": 2.5}]], + } + + assert HouseholdDocument.model_validate(situation).to_situation() == situation + + +def test_a_household_accepts_values_without_periods() -> None: + situation = { + "people": {"you": {"age": 40, "is_blind": False}}, + "households": {"your household": {"members": ["you"]}}, + } + + assert HouseholdDocument.model_validate(situation).to_situation() == situation + + +@pytest.mark.parametrize( + "situation", + [ + {"households": {"your household": {"members": ["you"]}}}, + {"people": {}}, + {"people": {"you": {}}, "households": {"your household": {}}}, + {"people": {"you": {}}, "households": {"your household": {"members": []}}}, + { + "people": {"you": {}}, + "households": {"your household": {"members": ["you", "you"]}}, + }, + {"people": {"you": {"age": {"2026": float("inf")}}}}, + {"people": {"you": {"age": {"2026": float("nan")}}}}, + {"people": {"you": {"age": {"2026": [40, 41]}}}}, + {"people": {"you": {}}, "axes": [[]]}, + {"people": {"you": {}}, "axes": [{"name": "age", "count": 2}]}, + {"people": {"you": {}}, "axes": [[{"name": "age", "count": 0}]]}, + ], +) +def test_a_household_rejects_malformed_situations(situation: dict) -> None: + with pytest.raises(ValidationError): + HouseholdDocument.model_validate(situation) + + +def test_axes_are_bounded_by_their_total_point_count() -> None: + axis = {"name": "employment_income", "min": 0, "max": 1, "period": "2026"} + within = { + "people": {"you": {}}, + "axes": [[{**axis, "count": 100}], [{**axis, "count": 100}]], + } + beyond = { + "people": {"you": {}}, + "axes": [[{**axis, "count": MAXIMUM_AXIS_POINTS}], [{**axis, "count": 2}]], + } + + assert HouseholdDocument.model_validate(within).axis_point_count() == 10_000 + with pytest.raises(ValidationError, match="axis points"): + HouseholdDocument.model_validate(beyond) + + +def test_parallel_axes_must_share_one_point_count() -> None: + axis = {"name": "employment_income", "min": 0, "max": 1} + + with pytest.raises(ValidationError, match="parallel axes"): + HouseholdDocument.model_validate( + { + "people": {"you": {}, "partner": {}}, + "axes": [[{**axis, "count": 3}, {**axis, "count": 4, "index": 1}]], + } + ) + + +def test_one_request_carries_the_baseline_and_its_reform() -> None: + request = _request() + + assert [policy.policy_key for policy in request.policies] == ["baseline", "reform"] + assert request.policies[1].parameters == CTC_REFORM + assert request.schema_version == 1 + assert request.add_missing_variables is False + assert request.spm_selection_source == "request" + assert request.bundle == _bundle() + + +def test_a_request_names_the_year_it_calculates() -> None: + request = _request(year="2025") + + assert request.year == "2025" + assert request.model_dump(mode="json")["year"] == "2025" + assert ( + HouseholdExecutionRequest.model_validate(request.model_dump(mode="json")) + == request + ) + + +def test_a_request_without_a_year_is_refused() -> None: + values = _request().model_dump(mode="json") + del values["year"] + + with pytest.raises(ValidationError, match="year"): + HouseholdExecutionRequest.model_validate(values) + + +@pytest.mark.parametrize("year", ["25", "20255", "2025-01", "year", "", " 2025", 2025]) +def test_a_request_with_a_malformed_year_is_refused(year: object) -> None: + with pytest.raises(ValidationError, match="year"): + _request(year=year) + + +def test_add_missing_variables_asks_for_a_full_calculation() -> None: + assert _request(add_missing_variables=True).add_missing_variables is True + + +def test_a_request_needs_one_to_ten_policies_with_unique_keys() -> None: + eleven_policies = [ + {"policy_key": f"policy-{index}", "parameters": {}} + for index in range(MAXIMUM_POLICIES_PER_REQUEST + 1) + ] + repeated_key = [ + {"policy_key": "reform", "parameters": {}}, + {"policy_key": "reform", "parameters": CTC_REFORM}, + ] + + assert len(_request(policies=eleven_policies[:10]).policies) == 10 + with pytest.raises(ValidationError): + _request(policies=[]) + with pytest.raises(ValidationError): + _request(policies=eleven_policies) + with pytest.raises(ValidationError, match="unique"): + _request(policies=repeated_key) + with pytest.raises(ValidationError): + _request(policies=[{"policy_key": "Not A Key", "parameters": {}}]) + + +def test_omitted_spm_inherits_the_defaults_and_is_not_a_choice() -> None: + request = _request() + + assert request.spm is None + assert request.spm_requested is False + dumped = request.model_dump(mode="json") + assert "spm" not in dumped + assert HouseholdExecutionRequest.model_validate(dumped).spm_requested is False + + +def test_an_empty_spm_selection_is_still_a_choice() -> None: + request = _request(spm={}, spm_selection_source="saved_household") + + assert request.spm_requested is True + dumped = request.model_dump(mode="json") + assert dumped["spm"] == {} + assert dumped["spm_selection_source"] == "saved_household" + assert HouseholdExecutionRequest.model_validate(dumped).spm_requested is True + + +def test_spm_settings_travel_exactly_as_the_caller_sent_them() -> None: + # The household function checks them, so a malformed selection or an + # explicit null fails with the Public API's SPM error codes. + for sent in (None, {"geography_kind": "planet"}, "national"): + request = _request(spm=sent) + + assert request.spm == sent + assert request.spm_requested is True + dumped = request.model_dump(mode="json") + assert dumped["spm"] == sent + transported = HouseholdExecutionRequest.model_validate(dumped) + assert transported.spm == sent + assert transported.spm_requested is True + + +def test_a_request_rejects_unknown_fields_and_other_schema_versions() -> None: + with pytest.raises(ValidationError): + _request(unexpected=True) + with pytest.raises(ValidationError): + _request(schema_version=2) + with pytest.raises(ValidationError): + _request(country="ca") + + +def test_a_calculated_household_keeps_axis_results_and_non_finite_strings() -> None: + calculated = { + "people": { + "you": { + "age": {"2026": [40, 40]}, + "marginal_tax_rate": {"2026": ["Infinity", 0.25]}, + "is_blind": {"2026": [False, False]}, + } + }, + "households": { + "your household": { + "members": ["you"], + "state_name": {"2026": ["CA", "CA"]}, + "spm_unit_net_income": {"2026": [None, None]}, + } + }, + "axes": [[{"name": "employment_income", "count": 2, "min": 0, "max": 1}]], + } + + assert _calculated(calculated).to_situation() == calculated + + +def test_execution_outcomes_are_told_apart_by_status() -> None: + succeeded = HouseholdExecutionResult( + calculation_id=CALCULATION_ID, output=_output(), worker=_worker() + ) + failed = HouseholdExecutionFailure( + calculation_id=CALCULATION_ID, + error=HouseholdCalculationError( + code="invalid_spm_selection", + message="SPM settings are only available for the US", + spm_error=SPMErrorDetail( + code="SPM_SETTINGS_UNSUPPORTED", + message="SPM settings are only available for the US", + ), + ), + worker=_worker(), + ) + + parsed_success = parse_household_execution_outcome( + succeeded.model_dump(mode="json") + ) + parsed_failure = parse_household_execution_outcome(failed.model_dump(mode="json")) + + assert isinstance(parsed_success, HouseholdExecutionResult) + assert parsed_success == succeeded + assert parsed_success.schema_version == 1 + assert isinstance(parsed_failure, HouseholdExecutionFailure) + assert parsed_failure == failed + with pytest.raises(ValidationError): + parse_household_execution_outcome({"status": "running"}) + + +def test_input_errors_serialize_as_the_public_api_lists_them() -> None: + wrong_entity = HouseholdInputError( + type="household_variable_wrong_entity", + name="state_name", + path="household.people.you.state_name", + message="Household variable `state_name` belongs on `households` ...", + expected_entity_plural="households", + actual_entity_plural="people", + ) + unknown = HouseholdInputError( + type="household_variable", + name="not_a_variable", + path="household.people.you.not_a_variable", + message="Unrecognized household variable ...", + ) + + assert wrong_entity.model_dump(mode="json") == { + "type": "household_variable_wrong_entity", + "name": "state_name", + "path": "household.people.you.state_name", + "message": "Household variable `state_name` belongs on `households` ...", + "expected_entity_plural": "households", + "actual_entity_plural": "people", + } + # Like the Public API's error entries, an error names entity plurals only + # for a variable on the wrong entity. + assert unknown.model_dump(mode="json") == { + "type": "household_variable", + "name": "not_a_variable", + "path": "household.people.you.not_a_variable", + "message": "Unrecognized household variable ...", + } + assert HouseholdInputError.model_validate(unknown.model_dump()) == unknown + + +def test_output_results_must_have_unique_policy_keys() -> None: + result = _output().results[0] + + with pytest.raises(ValidationError, match="unique"): + _output(results=(result, result)) + + +def test_output_carries_the_applied_spm_selection_and_each_receipt() -> None: + receipt = build_spm_provenance( + forecast_id="forecast-2026", + forecast_sha256="c" * 64, + selection=RESOLVED_SPM_SELECTION, + years=("2026",), + runtime_versions=SPM_RUNTIME_VERSIONS, + ) + applied = SPMResolvedConfiguration.model_validate( + RESOLVED_SPM_SELECTION.model_dump(mode="json") + ) + result = _output().results[0].model_copy(update={"spm_provenance": receipt}) + + output = _output(results=(result,), spm_config=applied) + transported = HouseholdCalculationOutput.model_validate( + json.loads(output.model_dump_json()) + ) + + assert transported.spm_config == applied + assert transported.results[0].spm_provenance == receipt + assert transported.model_dump(mode="json")["spm_config"] == { + "forecast_content_sha256": "c" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + assert _output().spm_config is None + + +def test_bundle_provenance_is_read_from_a_normalized_bundle() -> None: + dataset = Stage12Dataset( + identity="populace_uk_2023", + uri="hf://policyengine/data/populace_uk_2023.h5@r1", + artifact_revision="r1", + sha256="c" * 64, + repo_type="dataset", + ) + bundle = Stage12BundleManifest( + policyengine_version="6.7.5", + policyengine_requirement="policyengine==6.7.5", + core_package_version="3.33.0", + core_package_requirement="policyengine-core==3.33.0", + countries=( + Stage12CountryBundle( + country="uk", + country_package_name="policyengine-uk", + country_package_version="2.130.1", + country_package_requirement="policyengine-uk==2.130.1", + data_package_name="populace-data", + data_package_version="0.1.0", + data_release_version="r1", + data_artifact_revision="r1", + default_dataset="populace_uk_2023", + default_dataset_uri=dataset.uri, + datasets=(dataset,), + ), + ), + ) + + provenance = HouseholdBundleProvenance.from_bundle( + bundle, bundle_manifest_sha256="d" * 64, country="uk" + ) + + assert provenance.model_dump() == { + "policyengine_version": "6.7.5", + "core_package_version": "3.33.0", + "country_package_name": "policyengine-uk", + "country_package_version": "2.130.1", + "bundle_manifest_sha256": "d" * 64, + } + with pytest.raises(ValueError, match="does not carry"): + HouseholdBundleProvenance.from_bundle( + bundle, bundle_manifest_sha256="d" * 64, country="us" + ) + + +def test_household_functions_are_named_by_country() -> None: + assert household_callable_name("us") == "calculate_household_us" + assert household_callable_name("uk") == "calculate_household_uk" + + +def test_policy_values_may_be_lists_for_list_parameters() -> None: + policy = HouseholdPolicy.model_validate( + { + "policy_key": "reform", + "parameters": { + "gov.usda.snap.income.sources.earned": { + "2026-01-01.2100-12-31": ["employment_income"] + } + }, + } + ) + + assert policy.parameters["gov.usda.snap.income.sources.earned"] == { + "2026-01-01.2100-12-31": ["employment_income"] + } diff --git a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py index 78c1f908e..eb3b464af 100644 --- a/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py +++ b/libs/policyengine-simulation-contract/tests/test_stage12_manifest.py @@ -17,18 +17,22 @@ ACTIVE_MANIFEST_KEY, PRE_CACHE_MANIFEST_KEY, REGION_DATASETS_MANIFEST_KEY, + SCHEMA_2_MANIFEST_KEY, V1_ROUTING_STATE_NAME, V2_VERSION_MANIFEST_NAME, Stage12CacheRelease, V2CountryWorker, V2ManifestLoader, + V2VersionManifest, V2WorkerValidation, V2WorkerVersion, assert_separate_manifest_names, manifest_digest, + parse_v2_manifest, pre_cache_manifest_payload, pre_region_datasets_manifest_payload, publish_v2_manifest, + schema_1_manifest_payload, v2_application_name, validate_manifest_has_no_credentials, ) @@ -90,22 +94,39 @@ def _resolved_bundle(version: str = "5.2.0") -> ResolvedStage12Bundle: ) -def _worker(version: str = "5.2.0") -> V2WorkerVersion: +def _country_worker( + country: str, *, economy: bool = True, household: bool = False +) -> V2CountryWorker: + return V2CountryWorker( + country=country, + economy_capable=economy, + single_simulation_callable=( + f"run_single_simulation_{country}" if economy else None + ), + household_callable=f"calculate_household_{country}" if household else None, + ) + + +def _worker( + version: str = "5.2.0", + *, + with_household_functions: bool = False, + household_only: tuple[str, ...] = (), +) -> V2WorkerVersion: resolved = _resolved_bundle(version) application_name = v2_application_name(version) + countries = tuple( + _country_worker( + country, + economy=country not in household_only, + household=with_household_functions or country in household_only, + ) + for country in ("us", "uk") + ) return V2WorkerVersion( application_name=application_name, report_coordinator_callable="coordinate_report", - countries=( - V2CountryWorker( - country="us", - single_simulation_callable="run_single_simulation_us", - ), - V2CountryWorker( - country="uk", - single_simulation_callable="run_single_simulation_uk", - ), - ), + countries=countries, bundle=resolved.bundle, bundle_manifest_sha256=resolved.bundle_manifest_sha256, validation=V2WorkerValidation( @@ -115,8 +136,14 @@ def _worker(version: str = "5.2.0") -> V2WorkerVersion: validated_at=datetime(2026, 9, 14, tzinfo=UTC), validation_invocation_id="validation-123", country_validation_invocation_ids={ - "us": "validation-us-123", - "uk": "validation-uk-123", + worker.country: f"validation-{worker.country}-123" + for worker in countries + if worker.economy_capable + }, + household_validation_invocation_ids={ + worker.country: f"household-validation-{worker.country}-123" + for worker in countries + if worker.household_callable is not None }, ), ) @@ -127,8 +154,10 @@ def test_v2_application_name_cannot_collide_with_v1() -> None: assert not v2_application_name("5.2.0").startswith("policyengine-simulation-py") -def _cached_worker(version: str = "5.2.0") -> V2WorkerVersion: - worker = _worker(version) +def _cached_worker( + version: str = "5.2.0", *, with_household_functions: bool = False +) -> V2WorkerVersion: + worker = _worker(version, with_household_functions=with_household_functions) cache = Stage12CacheRelease( manifest_sha256="c" * 64, partition_sha256="d" * 64, @@ -163,9 +192,11 @@ def test_v2_application_name_rejects_versions_a_modal_name_cannot_hold( v2_application_name(version) -def _regional_worker(version: str = "5.2.0") -> V2WorkerVersion: +def _regional_worker( + version: str = "5.2.0", *, with_household_functions: bool = False +) -> V2WorkerVersion: """Return a cached worker whose US bundle names each region type's dataset.""" - worker = _cached_worker(version) + worker = _cached_worker(version, with_household_functions=with_household_functions) countries = tuple( country.model_copy( update={ @@ -191,31 +222,82 @@ def _regional_worker(version: str = "5.2.0") -> V2WorkerVersion: def test_publish_writes_one_complete_v2_document_per_key() -> None: store = FakeStore() - manifest = publish_v2_manifest(store=store, worker=_regional_worker()) + manifest = publish_v2_manifest( + store=store, worker=_regional_worker(with_household_functions=True) + ) assert store.writes == [ - (REGION_DATASETS_MANIFEST_KEY, manifest.model_dump(mode="json")), + (SCHEMA_2_MANIFEST_KEY, manifest.model_dump(mode="json")), + (REGION_DATASETS_MANIFEST_KEY, schema_1_manifest_payload(manifest)), (ACTIVE_MANIFEST_KEY, pre_region_datasets_manifest_payload(manifest)), (PRE_CACHE_MANIFEST_KEY, pre_cache_manifest_payload(manifest)), ] + assert manifest.schema_version == 2 assert manifest.default_version == "5.2.0" assert manifest.versions["5.2.0"].validation.validated is True - assert V2ManifestLoader(store).load().versions["5.2.0"].cache is not None + loaded = V2ManifestLoader(store).load() + assert loaded == manifest + assert loaded.versions["5.2.0"].cache is not None + assert loaded.versions["5.2.0"].household_worker("us") is not None + + +def _assert_schema_1_top_level(payload: object) -> dict: + # The fields of the schema version 1 manifest and country models. Readers + # released before schema version 2 forbid any other field, so adding one + # here would stop them loading. + assert isinstance(payload, dict) + assert payload["schema_version"] == 1 + assert set(payload) == { + "default_version", + "generation", + "schema_version", + "versions", + } + for country in payload["versions"]["5.2.0"]["countries"]: + assert set(country) == {"country", "single_simulation_callable"} + return payload + + +@pytest.mark.parametrize("key", [REGION_DATASETS_MANIFEST_KEY, ACTIVE_MANIFEST_KEY]) +def test_readers_released_before_schema_2_get_only_fields_they_accept( + key: str, +) -> None: + store = FakeStore() + publish_v2_manifest( + store=store, worker=_regional_worker(with_household_functions=True) + ) + + with_cache = _assert_schema_1_top_level(store.values[key]) + worker = with_cache["versions"]["5.2.0"] + assert set(worker) == { + "application_name", + "bundle", + "bundle_manifest_sha256", + "cache", + "countries", + "report_coordinator_callable", + "validation", + } + assert set(worker["validation"]) == { + "application_name", + "bundle_manifest_sha256", + "cache", + "country_validation_invocation_ids", + "validated", + "validated_at", + "validation_invocation_id", + } def test_readers_released_before_the_cache_get_only_fields_they_accept() -> None: store = FakeStore() - publish_v2_manifest(store=store, worker=_cached_worker()) + publish_v2_manifest( + store=store, worker=_cached_worker(with_household_functions=True) + ) # The fields of each model before Stage 12 cache releases. Those readers # forbid any other field, so adding one here would stop them loading. - pre_cache = store.values[PRE_CACHE_MANIFEST_KEY] - assert set(pre_cache) == { - "default_version", - "generation", - "schema_version", - "versions", - } + pre_cache = _assert_schema_1_top_level(store.values[PRE_CACHE_MANIFEST_KEY]) worker = pre_cache["versions"]["5.2.0"] assert set(worker) == { "application_name", @@ -264,16 +346,18 @@ def test_readers_released_before_region_datasets_get_only_fields_they_accept( assert set(country) == _COUNTRY_BUNDLE_FIELDS_BEFORE_REGION_DATASETS -def test_the_region_dataset_key_keeps_region_datasets_and_cache() -> None: +@pytest.mark.parametrize("key", [SCHEMA_2_MANIFEST_KEY, REGION_DATASETS_MANIFEST_KEY]) +def test_the_region_dataset_keys_keep_region_datasets_and_cache(key: str) -> None: store = FakeStore() publish_v2_manifest(store=store, worker=_regional_worker()) - loaded = V2ManifestLoader(store).load().versions["5.2.0"] + loaded = parse_v2_manifest(store.values[key]).versions["5.2.0"] us = next(country for country in loaded.bundle.countries if country.country == "us") assert us.dataset_for_region_type("state") == "populace_us_2024_local" assert us.dataset_for_region_type("national") == "populace_us_2024" assert us.dataset_for_region_type("congressional_district") is None assert loaded.cache is not None + assert V2ManifestLoader(store).load().versions["5.2.0"] == loaded def test_a_bundle_published_before_region_datasets_has_none() -> None: @@ -304,39 +388,232 @@ def test_first_publish_with_region_datasets_keeps_versions_from_the_cache_key() assert V2ManifestLoader(store).load() == manifest -def test_first_publish_with_cache_keeps_versions_from_the_pre_cache_key() -> None: - earlier = _worker("5.1.0").model_dump( - mode="json", exclude={"cache": True, "validation": {"cache"}} +def _schema_1_document(*, include_cache: bool, version: str = "5.1.0") -> dict: + """Return a manifest exactly as a publisher released before schema 2 wrote it.""" + worker = _worker(version).model_dump( + mode="json", + exclude={ + "countries": {"__all__": {"economy_capable", "household_callable"}}, + "validation": ( + {"household_validation_invocation_ids"} + | (set() if include_cache else {"cache"}) + ), + "bundle": {"countries": {"__all__": {"region_datasets"}}}, + **({} if include_cache else {"cache": True}), + }, ) - store = FakeStore( - { - PRE_CACHE_MANIFEST_KEY: { - "schema_version": 1, - "generation": "5.1.0:earlier", - "default_version": "5.1.0", - "versions": {"5.1.0": earlier}, - } - } + return { + "schema_version": 1, + "generation": f"{version}:earlier", + "default_version": version, + "versions": {version: worker}, + } + + +@pytest.mark.parametrize( + ("key", "include_cache"), + [(ACTIVE_MANIFEST_KEY, True), (PRE_CACHE_MANIFEST_KEY, False)], +) +def test_loader_reads_a_schema_1_document_as_economy_capable_countries( + *, key: str, include_cache: bool +) -> None: + store = FakeStore({key: _schema_1_document(include_cache=include_cache)}) + + manifest = V2ManifestLoader(store).load() + + assert manifest.schema_version == 2 + worker = manifest.versions["5.1.0"] + assert [country.economy_capable for country in worker.countries] == [True, True] + assert worker.economy_worker("uk") == _country_worker("uk") + assert worker.household_worker("us") is None + assert worker.validation.household_validation_invocation_ids == {} + + +def test_loader_reads_the_schema_2_document_its_publish_wrote() -> None: + store = FakeStore({ACTIVE_MANIFEST_KEY: _schema_1_document(include_cache=True)}) + newer = publish_v2_manifest( + store=store, worker=_worker(with_household_functions=True) ) + + loaded = V2ManifestLoader(store).load() + + # Only the schema version 2 document names the household functions. + assert loaded == newer + assert loaded.versions["5.2.0"].household_worker("us") is not None + + +@pytest.mark.parametrize( + ("key", "include_cache"), + [(ACTIVE_MANIFEST_KEY, True), (PRE_CACHE_MANIFEST_KEY, False)], +) +def test_first_schema_2_publish_keeps_versions_from_a_schema_1_key( + *, key: str, include_cache: bool +) -> None: + store = FakeStore({key: _schema_1_document(include_cache=include_cache)}) assert V2ManifestLoader(store).load().default_version == "5.1.0" - manifest = publish_v2_manifest(store=store, worker=_cached_worker()) + manifest = publish_v2_manifest( + store=store, worker=_cached_worker(with_household_functions=True) + ) assert sorted(manifest.versions) == ["5.1.0", "5.2.0"] + assert manifest.versions["5.1.0"].household_worker("us") is None + assert manifest.versions["5.2.0"].household_worker("us") is not None assert V2ManifestLoader(store).load() == manifest +def test_a_household_only_country_leaves_schema_1_readers_on_their_manifest() -> None: + earlier = _schema_1_document(include_cache=True) + store = FakeStore({ACTIVE_MANIFEST_KEY: earlier}) + + manifest = publish_v2_manifest(store=store, worker=_worker(household_only=("uk",))) + + # Schema version 1 cannot describe a country without a single-simulation + # function, so only the schema version 2 document changes. + assert [key for key, _ in store.writes] == [SCHEMA_2_MANIFEST_KEY] + assert store.values[ACTIVE_MANIFEST_KEY] == earlier + assert schema_1_manifest_payload(manifest, include_cache=True) is None + worker = manifest.versions["5.2.0"] + assert worker.economy_worker("uk") is None + assert worker.household_worker("uk") is not None + assert worker.economy_worker("us") is not None + + +def _validated_on(worker: V2WorkerVersion, day: int) -> V2WorkerVersion: + """Return ``worker`` as validated on ``day`` of September 2026.""" + validation = worker.validation.model_copy( + update={"validated_at": datetime(2026, 9, day, tzinfo=UTC)} + ) + return worker.model_copy(update={"validation": validation}) + + +def _write_as_a_schema_1_publisher( + store: FakeStore, *workers: V2WorkerVersion, default_version: str +) -> None: + """Write the keys a publisher released before region datasets writes.""" + manifest = V2VersionManifest( + generation="older-publisher", + default_version=default_version, + versions={worker.bundle.policyengine_version: worker for worker in workers}, + ) + store[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(manifest) + store[PRE_CACHE_MANIFEST_KEY] = pre_cache_manifest_payload(manifest) + + +def test_a_publish_keeps_what_an_older_publisher_wrote_after_it() -> None: + # A deployment of code released before schema 2 (for example after a + # revert) redeploys 5.2.0 without household functions and writes only the + # older keys. The next schema 2 publish must start from that, not from + # its own stale document. + store = FakeStore() + publish_v2_manifest( + store=store, + worker=_validated_on(_worker(with_household_functions=True), 14), + ) + _write_as_a_schema_1_publisher( + store, _validated_on(_worker(), 20), default_version="5.2.0" + ) + + manifest = publish_v2_manifest( + store=store, + worker=_validated_on(_worker("5.3.0", with_household_functions=True), 25), + ) + + assert sorted(manifest.versions) == ["5.2.0", "5.3.0"] + assert manifest.versions["5.2.0"].household_worker("us") is None + assert manifest.versions["5.2.0"].validation.validated_at.day == 20 + assert manifest.default_version == "5.3.0" + + +def test_a_publish_keeps_the_default_of_the_latest_document() -> None: + store = FakeStore() + publish_v2_manifest(store=store, worker=_validated_on(_worker("5.3.0"), 14)) + # An older publisher later made 5.2.0 the default. + _write_as_a_schema_1_publisher( + store, + _validated_on(_worker("5.3.0"), 14), + _validated_on(_worker("5.2.0"), 20), + default_version="5.2.0", + ) + + manifest = publish_v2_manifest( + store=store, worker=_validated_on(_worker("5.2.5"), 25) + ) + + # 5.2.5 is newer than the latest default, 5.2.0, so it becomes the default. + assert manifest.default_version == "5.2.5" + assert sorted(manifest.versions) == ["5.2.0", "5.2.5", "5.3.0"] + + +def test_older_keys_left_behind_by_a_schema_2_publish_change_nothing() -> None: + store = FakeStore({ACTIVE_MANIFEST_KEY: _schema_1_document(include_cache=True)}) + publish_v2_manifest( + store=store, worker=_validated_on(_worker(household_only=("uk",)), 14) + ) + + manifest = publish_v2_manifest( + store=store, + worker=_validated_on(_worker("5.3.0", household_only=("uk",)), 25), + ) + + assert sorted(manifest.versions) == ["5.1.0", "5.2.0", "5.3.0"] + assert manifest.versions["5.2.0"].economy_worker("uk") is None + assert manifest.default_version == "5.3.0" + + +def test_only_an_economy_capable_country_names_a_simulation_callable() -> None: + worker = _worker(household_only=("uk",)) + + assert worker.economy_simulation_callable("us") == "run_single_simulation_us" + with pytest.raises(LookupError, match="'uk' is not economy-capable"): + worker.economy_simulation_callable("uk") + with pytest.raises(LookupError, match="'fr' is not economy-capable"): + worker.economy_simulation_callable("fr") + + +def test_parse_v2_manifest_rejects_unknown_schema_versions() -> None: + document = _schema_1_document(include_cache=True) + document["schema_version"] = 3 + + with pytest.raises(ValueError, match="schema_version"): + parse_v2_manifest(document) + + def test_loader_retains_its_last_valid_v2_document() -> None: store = FakeStore() expected = publish_v2_manifest(store=store, worker=_worker()) loader = V2ManifestLoader(store) assert loader.load() == expected - store.values[ACTIVE_MANIFEST_KEY] = {"schema_version": 999} + store.values[SCHEMA_2_MANIFEST_KEY] = {"schema_version": 999} assert loader.load() == expected +def test_a_publish_refuses_an_unreadable_schema_2_document() -> None: + store = FakeStore() + publish_v2_manifest(store=store, worker=_worker()) + store.values[SCHEMA_2_MANIFEST_KEY] = {"schema_version": 999} + + with pytest.raises(ValueError, match="schema_version"): + publish_v2_manifest(store=store, worker=_worker("5.3.0")) + + +@pytest.mark.parametrize("include_cache", [True, False]) +def test_older_publisher_fixtures_hold_only_released_fields( + include_cache: bool, # noqa: FBT001 # pytest passes parameters by name +) -> None: + # The documents these tests store as older publishers' output carry only + # the fields those publishers wrote. + document = _schema_1_document(include_cache=include_cache) + worker = _assert_schema_1_top_level( + {**document, "versions": {"5.2.0": document["versions"]["5.1.0"]}} + )["versions"]["5.2.0"] + for country in worker["bundle"]["countries"]: + assert set(country) == _COUNTRY_BUNDLE_FIELDS_BEFORE_REGION_DATASETS + assert ("cache" in worker) is include_cache + + def test_loader_never_falls_back_to_v1_state() -> None: v1_store = FakeStore({ACTIVE_MANIFEST_KEY: {"schema_version": 1}}) v2_store = FakeStore() @@ -363,7 +640,7 @@ def test_v1_and_v2_publication_and_removal_are_bidirectionally_isolated() -> Non manifest ) - v2_store.values.pop(ACTIVE_MANIFEST_KEY) + v2_store.values.pop(SCHEMA_2_MANIFEST_KEY) assert v1_store.values == {ACTIVE_MANIFEST_KEY: {"v1": "new"}} @@ -398,6 +675,46 @@ def test_cache_metadata_is_optional_for_old_workers_and_must_match_validation() V2WorkerVersion.model_validate(value) +@pytest.mark.parametrize( + ("economy_capable", "single_simulation_callable", "household_callable"), + [ + (True, None, None), + (False, "run_single_simulation_us", None), + (False, "run_single_simulation_us", "calculate_household_us"), + (False, None, None), + ], +) +def test_country_capabilities_must_name_their_functions( + *, + economy_capable: bool, + single_simulation_callable: str | None, + household_callable: str | None, +) -> None: + with pytest.raises(ValueError, match=r"economy-capable|capability"): + V2CountryWorker( + country="us", + economy_capable=economy_capable, + single_simulation_callable=single_simulation_callable, + household_callable=household_callable, + ) + + +def test_household_functions_need_a_validation_receipt() -> None: + value = _worker(with_household_functions=True).model_dump(mode="json") + value["validation"]["household_validation_invocation_ids"] = {"us": "only-us"} + + with pytest.raises(ValueError, match="household"): + V2WorkerVersion.model_validate(value) + + +def test_only_economy_capable_countries_have_simulation_receipts() -> None: + value = _worker(household_only=("uk",)).model_dump(mode="json") + value["validation"]["country_validation_invocation_ids"]["uk"] = "not-needed" + + with pytest.raises(ValueError, match="economy-capable"): + V2WorkerVersion.model_validate(value) + + def test_manifest_rejects_credential_like_fields() -> None: with pytest.raises(ValueError, match="credential-like"): validate_manifest_has_no_credentials( @@ -479,3 +796,42 @@ def test_a_publish_after_a_rollback_keeps_the_versions_the_older_code_added() -> manifest = publish_v2_manifest(store=store, worker=_regional_worker("5.4.0")) assert sorted(manifest.versions) == ["5.3.0", "5.4.0"] + + +def test_a_schema_2_document_left_behind_by_an_older_publish_is_not_read() -> None: + """After a rollback, code released before schema 2 writes only older keys.""" + store = FakeStore() + publish_v2_manifest( + store=store, worker=_regional_worker(with_household_functions=True) + ) + stale = store.values[SCHEMA_2_MANIFEST_KEY] + older = publish_v2_manifest(store=FakeStore(), worker=_regional_worker("5.3.0")) + store.values[REGION_DATASETS_MANIFEST_KEY] = schema_1_manifest_payload(older) + store.values[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(older) + store.values[PRE_CACHE_MANIFEST_KEY] = pre_cache_manifest_payload(older) + + loaded = V2ManifestLoader(store).load() + manifest = publish_v2_manifest(store=store, worker=_regional_worker("5.4.0")) + + assert loaded.default_version == "5.3.0" + assert loaded.versions["5.3.0"].household_worker("us") is None + assert sorted(manifest.versions) == ["5.3.0", "5.4.0"] + assert store.values[SCHEMA_2_MANIFEST_KEY] != stale + + +def test_a_household_only_manifest_is_read_until_an_older_publish_follows() -> None: + # Schema version 1 cannot describe a household-only country, so its + # publish leaves the older keys alone; a later validation under them + # means code released before schema 2 published after it. + store = FakeStore({ACTIVE_MANIFEST_KEY: _schema_1_document(include_cache=True)}) + household_only = publish_v2_manifest( + store=store, worker=_validated_on(_worker(household_only=("uk",)), 15) + ) + assert V2ManifestLoader(store).load() == household_only + + older = publish_v2_manifest( + store=FakeStore(), worker=_validated_on(_cached_worker("5.3.0"), 20) + ) + store.values[ACTIVE_MANIFEST_KEY] = pre_region_datasets_manifest_payload(older) + + assert V2ManifestLoader(store).load().default_version == "5.3.0" diff --git a/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py b/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py index aaa35eadd..14036f212 100644 --- a/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py +++ b/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/stages.py @@ -26,6 +26,7 @@ class RunConfiguration(StrEnum): STAGE12_CANONICAL_REPORT = "stage12_canonical_report" STAGE12_SIMULATION = "stage12_simulation" STAGE12_SEGMENT = "stage12_segment" + V2_HOUSEHOLD = "v2_household" class Stage(StrEnum): @@ -111,6 +112,10 @@ class Stage(StrEnum): STAGE12_AGGREGATION = "stage12_aggregation" STAGE12_AGGREGATE_ARTIFACT_WRITE = "stage12_aggregate_artifact_write" STAGE12_RESULT_COMPARISON = "stage12_result_comparison" + V2_HOUSEHOLD_EXECUTION = "v2_household_execution" + V2_HOUSEHOLD_COUNTRY_MODEL_LOAD = "v2_household_country_model_load" + V2_HOUSEHOLD_INPUT_NORMALIZATION = "v2_household_input_normalization" + V2_HOUSEHOLD_CALCULATION = "v2_household_calculation" @dataclass(frozen=True) @@ -271,6 +276,17 @@ def name(self, stage: Stage) -> str: Stage.STAGE12_CALCULATION, ), ), + # One v2 household function call: loading the country model, + # preparing the household, then one calculation per policy. + RunConfiguration.V2_HOUSEHOLD: StagePlan( + RunConfiguration.V2_HOUSEHOLD, + ( + Stage.V2_HOUSEHOLD_EXECUTION, + Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, + Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, + Stage.V2_HOUSEHOLD_CALCULATION, + ), + ), } ) @@ -285,3 +301,4 @@ def name(self, stage: Stage) -> str: ] STAGE12_SIMULATION_STAGES = RUN_STAGE_REGISTRY[RunConfiguration.STAGE12_SIMULATION] STAGE12_SEGMENT_STAGES = RUN_STAGE_REGISTRY[RunConfiguration.STAGE12_SEGMENT] +V2_HOUSEHOLD_STAGES = RUN_STAGE_REGISTRY[RunConfiguration.V2_HOUSEHOLD] diff --git a/libs/policyengine-simulation-observability/tests/test_stages.py b/libs/policyengine-simulation-observability/tests/test_stages.py index fbba9f721..0861363b9 100644 --- a/libs/policyengine-simulation-observability/tests/test_stages.py +++ b/libs/policyengine-simulation-observability/tests/test_stages.py @@ -54,3 +54,15 @@ def test_annual_status_polls_measure_the_job_progress_read() -> None: annual = RUN_STAGE_REGISTRY[RunConfiguration.ANNUAL_IMPACT] assert annual.name(Stage.MODAL_JOB_PROGRESS_READ) == "modal_job_progress_read" + + +def test_v2_household_plan_measures_model_load_inputs_and_each_policy() -> None: + household = RUN_STAGE_REGISTRY[RunConfiguration.V2_HOUSEHOLD] + + assert household.stages == ( + Stage.V2_HOUSEHOLD_EXECUTION, + Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, + Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, + Stage.V2_HOUSEHOLD_CALCULATION, + ) + assert household.name(Stage.V2_HOUSEHOLD_CALCULATION) == "v2_household_calculation" diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md new file mode 100644 index 000000000..6b37b3b26 --- /dev/null +++ b/libs/policyengine-simulation-persistence/README.md @@ -0,0 +1,261 @@ +# 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, either a household or a region code (`state/ca`) with an optional dataset name (null means the region's default dataset in each catalog version), and `options`, whatever else changes what it calculates, such as a household's axes (empty unless set) | +| `reports` | Which two simulations a report compares (an economy report needs a reform; a household report may have only a baseline), both of the report's own country and kind, with its year and options | +| `simulation_runs` | One execution of a simulation for a catalog version and year: the region code and dataset row it resolved in that version (economy runs only), its status, Modal call ID, timings, error, trigger (`initial`, `manual` or `system`) and attempt number | +| `report_runs` | One calculation of a report: a baseline and a reform simulation run of the same catalog version and year (no reform run for a single-simulation household report), the trigger, and, for an economy report, the comparison step's own status and Modal call | + +`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 household path reads the Public API's rows a household run is +calculated from (`HouseholdInputReader`): the catalog version by country and +PolicyEngine.py version; the household's document, saved SPM choice and +`default_year`; and the policy's parameter values by parameter name, each +period as the Public API's `YYYY-MM-DD.YYYY-MM-DD` read in UTC (an open end +is `2100-12-31`), refused when the run's catalog version lacks a parameter. +The runtime role needs `SELECT` on `HOUSEHOLD_READ_TABLES` +(`tax_benefit_models`, `tax_benefit_model_versions`, `households`, +`policies`, `parameters` and `parameter_values`). The tests' copies of those +tables are checked against the Public API's columns, recorded in +`tests/fixtures/public_api/household_read_columns.json`. + +### Household calculations + +A household calculation is one simulation run of a household simulation. +`HouseholdCalculationStore.find_or_create_simulation` returns the oldest +household simulation with the same country, household, policy, dynamic and +options (compared by value), or creates it. The Public API's households and +policies never change once stored, so a calculation submitted again finds +the same simulation, and `RunStore.start_simulation_run` reuses its run. +Two racing submissions may each create the simulation; later ones find the +older row. `start_run` starts or reuses its run, as `RunStore` does, and +`read_calculation` returns the run (status, timings, error and error +detail), its simulation and, once it succeeded, the computed household; an +economy run is not a household calculation and reads as none. + +A household report compares a baseline household simulation with an +optional reform. Each report run joins one run of each, and one Modal call +computes both households. `create_report` and `create_report_run` add them, +and `read_report` returns the report with a report run (its newest, unless +one is named) and both runs as calculations; an economy report reads as none. + +### Runs + +A simulation run's setup is its catalog version (one policyengine.py bundle +version for one country, which pins the country package, data release and +datasets) and its year. Its region and dataset keys include its catalog +version, through the Public API's unique constraints +`uq_regions_model_version_code` and `uq_datasets_id_model_version`, so a run +can use only its own version's rows; the caller resolves them in that version +(the simulation's region code, and the dataset it names or the region's +default dataset). A report run's simulation run keys include its catalog +version and year, so both of its runs share them. + +`RunStore.start_simulation_run` reuses the newest attempt for the same +simulation, catalog version and year while it is unfinished, or when it +succeeded within the last 28 days (output files are kept 30); a failed run is +never reused. Otherwise, and always when forced, it adds the next attempt. +Concurrent starts settle on one row through the unique attempt number. +`RunStore.create_report_run` joins one run of each of the report's simulations +and takes the catalog version and year from them; a report's status is its +newest report run's. The runtime role may read, insert and update runs but +not delete them. + +A failure records its error code, a message that carries no user data, +and optionally a detail document (`error_detail`, or +`comparison_error_detail` for a comparison step), such as the inputs a +household calculation rejected; only a failed run or step has one. + +`RunTransitions` makes every status change one conditional update on the +expected status, so a second writer changes nothing: only the caller that +moves runs from `pending` to `dispatching` starts their Modal call, the runs +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. Next to it are the +calculation's warnings, in order, and three `jsonb` documents, each null when +it does not apply: the complete SPM selection the calculation applied +(`spm_config`), its SPM receipt (`spm_provenance`) and the PolicyEngine.py +bundle it ran with (`bundle`). `read_household_output` returns it all +unchanged. + +`output_files` links each stored file (its role, URI and SHA-256) to the one +simulation run or report run that wrote it. `available_output_files` offers a +link for 30 days after its file was written (`OUTPUT_FILE_RETENTION`), as long +as the artifact bucket keeps it; result rows stay for good. + +## Ownership rules + +These rules are registered in `rules/registry.toml` and checked by +`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`; on the Public API's tables, only `SELECT` on `HOUSEHOLD_READ_TABLES` | + +The initial revision grants the runtime role its privileges, including default +privileges on tables the migrator creates later; revision 0002 revokes +`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. diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml new file mode 100644 index 000000000..ffd61c5ea --- /dev/null +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -0,0 +1,57 @@ +[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", +] + +[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..bfb988592 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/__init__.py @@ -0,0 +1,147 @@ +"""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.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_calculations import ( + HouseholdCalculation, + HouseholdCalculationStore, + HouseholdReport, +) +from policyengine_simulation_persistence.household_reads import ( + HOUSEHOLD_READ_TABLES, + HouseholdCatalogVersion, + HouseholdInputReader, + PolicyParametersMissingError, + StoredHousehold, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, +) +from policyengine_simulation_persistence.household_results import ( + HouseholdOutput, + read_household_output, + 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, + 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", + "HEAD_REVISION", + "HOUSEHOLD_READ_TABLES", + "OPTIONAL_SECTIONS", + "OUTPUT_FILE_RETENTION", + "RESULT_FIELD_STORAGE", + "RUNTIME_ROLE", + "SIMULATION_SCHEMA", + "SUCCEEDED_RUN_REUSE_WINDOW", + "AbandonedDispatches", + "DueSimulationCall", + "EconomyComparisonRecorder", + "EconomyResultLabels", + "GeographyLabel", + "HouseholdCalculation", + "HouseholdCalculationStore", + "HouseholdCatalogVersion", + "HouseholdInputReader", + "HouseholdOutput", + "HouseholdReport", + "NewReport", + "NewReportRun", + "NewSimulation", + "NewSimulationRun", + "OutputFile", + "OutputFileRecord", + "PolicyParametersMissingError", + "ReportKind", + "ReportRecord", + "ReportRunRecord", + "ReportStore", + "RunError", + "RunErrorCode", + "RunStatus", + "RunStore", + "RunTransitions", + "RunTrigger", + "SimulationKind", + "SimulationRecord", + "SimulationRunRecord", + "SimulationStore", + "StartedSimulationRun", + "StoredHousehold", + "UnknownCatalogVersionError", + "UnknownHouseholdError", + "UnknownPolicyError", + "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/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_calculations.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py new file mode 100644 index 000000000..fec6a388c --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_calculations.py @@ -0,0 +1,280 @@ +"""Household calculations: household simulations found by what they calculate. + +A household calculation is one simulation run of a household simulation: one +household under one policy (null for current law), with an optional dynamic +and the options that change what it calculates, such as its axes. The Public +API's households and policies never change once stored, so those keys identify +the calculation's inputs, and a calculation submitted again finds the same +simulation and, through ``RunStore``, reuses its run. + +Two submissions racing may each create the simulation; both rows describe the +same calculation, and later submissions find the older one. That costs one +calculation and no wrong result, so no unique constraint is needed. + +``read_calculation`` returns a calculation as its reader sees it: the run's +status, timings and error (with its detail), its simulation, and, once it +succeeded, the computed household. + +A household report compares a baseline household simulation with an +optional reform. Each report run joins one run of each, and one Modal call +computes both households; ``read_report`` returns the report with a report +run (its newest, unless another is named) and both runs as calculations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sqlalchemy import cast, select +from sqlalchemy.dialects.postgresql import JSONB + +from policyengine_simulation_persistence.household_results import ( + HouseholdOutput, + read_household_output, +) +from policyengine_simulation_persistence.reports import ( + ReportKind, + ReportRecord, + ReportStore, + reports, +) +from policyengine_simulation_persistence.run_store import RunStore, utc_now +from policyengine_simulation_persistence.runs import ( + ReportRunRecord, + RunStatus, + SimulationRunRecord, + report_runs, + simulation_runs, +) +from policyengine_simulation_persistence.simulations import ( + SimulationKind, + SimulationRecord, + SimulationStore, + simulations, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import datetime + from uuid import UUID + + from sqlalchemy import Connection, Engine + + from policyengine_simulation_persistence.reports import NewReport + from policyengine_simulation_persistence.runs import ( + NewReportRun, + NewSimulationRun, + StartedSimulationRun, + ) + from policyengine_simulation_persistence.simulations import NewSimulation + + +@dataclass(frozen=True) +class HouseholdCalculation: + """A household calculation: its run, its simulation, and its household. + + ``output`` is set once the run succeeded. + """ + + run: SimulationRunRecord + simulation: SimulationRecord + output: HouseholdOutput | None + + +@dataclass(frozen=True) +class HouseholdReport: + """A household report, one of its runs, and that run's two calculations. + + ``report_run``, ``baseline`` and ``reform`` are None before the report's + first run; ``reform`` is None for a report with only a baseline. + """ + + report: ReportRecord + report_run: ReportRunRecord | None + baseline: HouseholdCalculation | None + reform: HouseholdCalculation | None + + +class HouseholdCalculationStore: + """Typed SQLAlchemy Core reads and inserts for household calculations.""" + + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime] = utc_now, + ) -> None: + """Use ``engine`` (the runtime role) and ``clock`` for run timestamps.""" + self.engine = engine + self._simulations = SimulationStore(engine) + self._reports = ReportStore(engine) + self._runs = RunStore(engine, clock=clock) + + def find_or_create_simulation( + self, new_simulation: NewSimulation + ) -> SimulationRecord: + """Return the oldest household simulation with these inputs, or create it. + + It matches the country, household, policy, dynamic and options; options + compare by value, whatever their key order. Raise ValueError for an + economy simulation. + """ + if new_simulation.kind is not SimulationKind.HOUSEHOLD: + msg = "only a household simulation is found by its household" + raise ValueError(msg) + with self.engine.connect() as connection: + row = ( + connection.execute( + select(simulations) + .where( + simulations.c.kind == SimulationKind.HOUSEHOLD.value, + simulations.c.country == new_simulation.country, + simulations.c.household_id == new_simulation.household_id, + simulations.c.policy_id.is_not_distinct_from( + new_simulation.policy_id + ), + simulations.c.dynamic_id.is_not_distinct_from( + new_simulation.dynamic_id + ), + simulations.c.options == cast(new_simulation.options, JSONB), + ) + .order_by(simulations.c.created_at, simulations.c.id) + .limit(1) + ) + .mappings() + .first() + ) + if row is not None: + return SimulationRecord.from_row(row) + return self._simulations.create(new_simulation) + + def start_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Return the reusable run for this setup, or create the next attempt. + + As ``RunStore.start_simulation_run``: an unfinished run, or one that + succeeded within the reuse window, is reused; a failed one never is. + """ + return self._runs.start_simulation_run(new_run, force=force) + + def get_simulation(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + return self._simulations.get(simulation_id) + + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation run ``run_id``, or None. + + An economy run is not a household calculation, so it reads as None. + """ + with self.engine.connect() as connection: + return _read_calculation(connection, run_id) + + # --- Household reports -------------------------------------------------- + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Insert one report and return it.""" + return self._reports.create(new_report) + + def get_report(self, report_id: UUID) -> ReportRecord | None: + """Return the household report with this ID, or None.""" + report = self._reports.get(report_id) + if report is None or report.kind is not ReportKind.HOUSEHOLD: + return None + return report + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Add a report run joining runs of the report's simulations.""" + return self._runs.create_report_run(new_report_run) + + def read_report( + self, report_id: UUID, *, report_run_id: UUID | None = None + ) -> HouseholdReport | None: + """Return a household report with a run and its two calculations. + + The run is ``report_run_id`` when given, and the report's newest run + otherwise. Return None for an economy report, an unknown report, or a + run of another report. + """ + with self.engine.connect() as connection: + report_row = ( + connection.execute( + select(reports).where( + reports.c.id == report_id, + reports.c.kind == ReportKind.HOUSEHOLD.value, + ) + ) + .mappings() + .first() + ) + if report_row is None: + return None + run_query = select(report_runs).where(report_runs.c.report_id == report_id) + if report_run_id is not None: + run_query = run_query.where(report_runs.c.id == report_run_id) + run_row = ( + connection.execute( + run_query.order_by( + report_runs.c.created_at.desc(), report_runs.c.id.desc() + ).limit(1) + ) + .mappings() + .first() + ) + if run_row is None and report_run_id is not None: + return None + report_run = None if run_row is None else ReportRunRecord.from_row(run_row) + baseline = ( + None + if report_run is None + else _read_calculation( + connection, report_run.baseline_simulation_run_id + ) + ) + reform = ( + None + if report_run is None or report_run.reform_simulation_run_id is None + else _read_calculation(connection, report_run.reform_simulation_run_id) + ) + return HouseholdReport( + report=ReportRecord.from_row(report_row), + report_run=report_run, + baseline=baseline, + reform=reform, + ) + + +def _read_calculation( + connection: Connection, run_id: UUID +) -> HouseholdCalculation | None: + row = ( + connection.execute( + select(simulation_runs).where( + simulation_runs.c.id == run_id, + simulation_runs.c.simulation_kind == SimulationKind.HOUSEHOLD.value, + ) + ) + .mappings() + .first() + ) + if row is None: + return None + run = SimulationRunRecord.from_row(row) + simulation_row = ( + connection.execute( + select(simulations).where(simulations.c.id == run.simulation_id) + ) + .mappings() + .one() + ) + output = ( + read_household_output(connection, run.id) + if run.status is RunStatus.SUCCEEDED + else None + ) + return HouseholdCalculation( + run=run, + simulation=SimulationRecord.from_row(simulation_row), + output=output, + ) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py new file mode 100644 index 000000000..d7e3895ef --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_reads.py @@ -0,0 +1,266 @@ +"""Read the Public API's rows a v2 household run is calculated from. + +A household run calculates one stored household under one policy, or current +law, on one catalog version. The Simulation Entrypoint reads, from the Public +API's tables in the same database: + +- the catalog version, by country and PolicyEngine.py version; +- the household, by country and ID: its document (people and entity records + in v2 form, with its saved SPM choice) and its ``default_year``; +- the parameter values the policy sets, by parameter name, checked against + the run's catalog version (``policy_parameters``). + +The runtime role needs ``SELECT`` on ``HOUSEHOLD_READ_TABLES``, which the +owner of the Public API's tables grants. These tables are described here with +only the columns read, in a metadata object of their own; nothing here writes +them. The tests' copies of them are checked against the Public API's columns. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC +from typing import TYPE_CHECKING, Any + +from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + Uuid, + select, +) +from sqlalchemy.dialects.postgresql import JSONB + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + from sqlalchemy import Engine + +household_read_metadata = MetaData() + +tax_benefit_models = Table( + "tax_benefit_models", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("name", String(32)), +) +tax_benefit_model_versions = Table( + "tax_benefit_model_versions", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("model_id", Uuid(as_uuid=True)), + Column("version", String(128)), +) +households = Table( + "households", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("country_id", String(2)), + Column("default_year", Integer), + Column("household_data", JSONB), +) +policies = Table( + "policies", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("country_id", String(2)), +) +parameters = Table( + "parameters", + household_read_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("name", String(512)), + Column("tax_benefit_model_version_id", Uuid(as_uuid=True)), +) +parameter_values = Table( + "parameter_values", + household_read_metadata, + Column("parameter_id", Uuid(as_uuid=True)), + Column("value_json", JSONB), + Column("start_date", DateTime(timezone=True)), + Column("end_date", DateTime(timezone=True)), + Column("policy_id", Uuid(as_uuid=True)), +) +# The Public API tables the runtime role reads; the operator grants SELECT. +HOUSEHOLD_READ_TABLES = tuple(household_read_metadata.tables) +# The household functions take the Public API's policy JSON, whose periods end +# on this date when a value has no end. +OPEN_END_DATE = "2100-12-31" +# A refused policy's error names at most this many parameters. +MAX_LISTED_PARAMETERS = 10 + + +class UnknownHouseholdError(LookupError): + """The Public API has no such household in the country.""" + + +class UnknownPolicyError(LookupError): + """The Public API has no such policy in the country.""" + + +class UnknownCatalogVersionError(LookupError): + """The country has no catalog version for the PolicyEngine.py version.""" + + +class PolicyParametersMissingError(ValueError): + """The policy changes parameters the run's catalog version does not have.""" + + def __init__(self, names: tuple[str, ...]) -> None: + """Name the missing parameters (at most ``MAX_LISTED_PARAMETERS``).""" + self.names = names + shown = ", ".join(names[:MAX_LISTED_PARAMETERS]) + more = len(names) - MAX_LISTED_PARAMETERS + suffix = f" and {more} more" if more > 0 else "" + super().__init__( + "the policy changes parameters this catalog version does not have: " + f"{shown}{suffix}" + ) + + +@dataclass(frozen=True) +class HouseholdCatalogVersion: + """One published PolicyEngine.py version of one country's catalog.""" + + id: UUID + country: str + policyengine_version: str + + +@dataclass(frozen=True) +class StoredHousehold: + """A household as the Public API stores it. + + ``document`` lists the people and each group entity as records with an + ``id`` and ``values``, each person naming its groups, and may hold the + saved SPM choice (``spm``). ``default_year`` is the year its values name, + or None when it has none. + """ + + id: UUID + country: str + default_year: int | None + document: dict[str, Any] + + +def _period_key(start: datetime, end: datetime | None) -> str: + """Return the Public API's policy period: ``YYYY-MM-DD.YYYY-MM-DD``. + + The Public API stores each date as midnight UTC; the dates are read in + UTC whatever the database session's time zone. A value without an end + runs to ``OPEN_END_DATE``. + """ + start_date = f"{start.astimezone(UTC):%Y-%m-%d}" + end_date = OPEN_END_DATE if end is None else f"{end.astimezone(UTC):%Y-%m-%d}" + return f"{start_date}.{end_date}" + + +class HouseholdInputReader: + """Typed SQLAlchemy Core reads of the Public API's household-run rows.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + def catalog_version( + self, country: str, policyengine_version: str + ) -> HouseholdCatalogVersion: + """Return the country's catalog version for a PolicyEngine.py version. + + Raise UnknownCatalogVersionError when the country has none. + """ + with self.engine.connect() as connection: + version_id = connection.execute( + select(tax_benefit_model_versions.c.id) + .join( + tax_benefit_models, + tax_benefit_models.c.id == tax_benefit_model_versions.c.model_id, + ) + .where( + tax_benefit_models.c.name == f"policyengine-{country}", + tax_benefit_model_versions.c.version == policyengine_version, + ) + ).scalar_one_or_none() + if version_id is None: + msg = ( + f"{country} has no catalog version for PolicyEngine.py " + f"{policyengine_version}" + ) + raise UnknownCatalogVersionError(msg) + return HouseholdCatalogVersion( + id=version_id, country=country, policyengine_version=policyengine_version + ) + + def household(self, country: str, household_id: UUID) -> StoredHousehold: + """Return the country's household; raise UnknownHouseholdError otherwise.""" + with self.engine.connect() as connection: + row = connection.execute( + select(households.c.default_year, households.c.household_data).where( + households.c.id == household_id, + households.c.country_id == country, + ) + ).first() + if row is None: + msg = f"{country} has no household {household_id}" + raise UnknownHouseholdError(msg) + return StoredHousehold( + id=household_id, + country=country, + default_year=row.default_year, + document=row.household_data, + ) + + def policy_parameters( + self, country: str, policy_id: UUID, catalog_version_id: UUID + ) -> dict[str, dict[str, Any]]: + """Return the values the country's policy sets, by name and period. + + The values are stored against parameters of the catalog version the + policy was saved on; a run on another version applies them by + parameter name. Raise UnknownPolicyError when the country has no such + policy, and PolicyParametersMissingError when the run's version lacks + a parameter it changes. + """ + with self.engine.connect() as connection: + policy = connection.execute( + select(policies.c.id).where( + policies.c.id == policy_id, policies.c.country_id == country + ) + ).scalar_one_or_none() + if policy is None: + msg = f"{country} has no policy {policy_id}" + raise UnknownPolicyError(msg) + values = connection.execute( + select( + parameters.c.name, + parameter_values.c.value_json, + parameter_values.c.start_date, + parameter_values.c.end_date, + ) + .join(parameters, parameters.c.id == parameter_values.c.parameter_id) + .where(parameter_values.c.policy_id == policy_id) + .order_by(parameters.c.name, parameter_values.c.start_date) + ).all() + names = sorted({row.name for row in values}) + available = set( + connection.execute( + select(parameters.c.name).where( + parameters.c.tax_benefit_model_version_id == catalog_version_id, + parameters.c.name.in_(names), + ) + ).scalars() + ) + missing = tuple(name for name in names if name not in available) + if missing: + raise PolicyParametersMissingError(missing) + changes: dict[str, dict[str, Any]] = {} + for row in values: + changes.setdefault(row.name, {})[ + _period_key(row.start_date, row.end_date) + ] = row.value_json + return changes diff --git a/libs/policyengine-simulation-persistence/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..d5cd515af --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/household_results.py @@ -0,0 +1,158 @@ +"""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. + +Next to it are the calculation's warnings, in order, and three documents +(``jsonb``, whose key order does not matter): the complete SPM selection the +calculation applied (``spm_config``), the SPM calculation receipt +(``spm_provenance``), and the PolicyEngine.py bundle it ran with +(``bundle``). Each is null when it does not apply, such as SPM for a UK +household. +""" + +from __future__ import annotations + +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, + text, +) +from sqlalchemy.dialects.postgresql import JSONB + +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), + Column("warnings", JSONB, nullable=False, server_default=text("'[]'::jsonb")), + Column("spm_config", JSONB(none_as_null=True), nullable=True), + Column("spm_provenance", JSONB(none_as_null=True), nullable=True), + Column("bundle", JSONB(none_as_null=True), nullable=True), + CheckConstraint("json_typeof(household) = 'object'", name="household"), + CheckConstraint("schema_version >= 1", name="schema_version"), + CheckConstraint("jsonb_typeof(warnings) = 'array'", name="warnings"), + *( + CheckConstraint( + f"{document} IS NULL OR jsonb_typeof({document}) = 'object'", + name=document, + ) + for document in ("spm_config", "spm_provenance", "bundle") + ), + ForeignKeyConstraint( + ["simulation_run_id"], + [simulation_runs.c.id], + name="fk_household_outputs_simulation_run", + ondelete="RESTRICT", + ), +) + + +class HouseholdOutput(BaseModel): + """A computed household, the version of its document's schema, and its notes. + + ``warnings`` lists, in order, what the calculation reported about its + inputs; ``spm_config``, ``spm_provenance`` and ``bundle`` are the applied + SPM selection, the SPM receipt and the PolicyEngine.py bundle, each None + when it does not apply. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: int = Field(ge=1) + household: dict[str, JsonValue] + warnings: tuple[str, ...] = () + spm_config: dict[str, JsonValue] | None = None + spm_provenance: dict[str, JsonValue] | None = None + bundle: dict[str, JsonValue] | None = None + + +def write_household_output( + 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, + output.spm_config, + output.spm_provenance, + output.bundle, + ], + allow_nan=False, + ) + except ValueError: + msg = "a computed household may not hold NaN or an infinity" + raise ValueError(msg) from None + connection.execute( + insert(household_outputs).values( + simulation_run_id=run.id, + household=output.household, + schema_version=output.schema_version, + warnings=list(output.warnings), + spm_config=output.spm_config, + spm_provenance=output.spm_provenance, + bundle=output.bundle, + ) + ) + + +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"], + warnings=tuple(row["warnings"]), + spm_config=row["spm_config"], + spm_provenance=row["spm_provenance"], + bundle=row["bundle"], + ) 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..e04547fc4 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_store.py @@ -0,0 +1,356 @@ +"""Find, create and read simulation runs and report runs. + +Starting a simulation run reuses the newest attempt for the same simulation, +catalog version and year while it is unfinished, or succeeded within the +reuse window; otherwise, and always when forced, it adds the next attempt. +Concurrent starts settle on one row through the unique attempt number: the +loser of the insert returns the winner's run. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence.reports import ReportKind, reports +from policyengine_simulation_persistence.runs import ( + UNFINISHED_STATUSES, + NewReportRun, + NewSimulationRun, + ReportRunRecord, + RunStatus, + SimulationRunRecord, + StartedSimulationRun, + report_runs, + simulation_runs, +) +from policyengine_simulation_persistence.simulations import ( + SimulationKind, + simulations, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlalchemy import ColumnElement, Connection, Engine, RowMapping + +# Output files are kept 30 days. A succeeded run is reused only if it finished +# within the last 28, so its files outlive the comparison step that reads them. +SUCCEEDED_RUN_REUSE_WINDOW = timedelta(days=28) + + +def utc_now() -> datetime: + """Return the current time in UTC.""" + return datetime.now(UTC) + + +def _check_region_code(simulation: RowMapping, new_run: NewSimulationRun) -> None: + """Reject an economy run whose region code is not its simulation's. + + A missing region or dataset, or one on a household run, is left to the + database's population check. + """ + if ( + simulation["kind"] == SimulationKind.ECONOMY.value + and new_run.region_code is not None + and new_run.region_code != simulation["region_code"] + ): + msg = "an economy run's region_code must be its simulation's region code" + raise ValueError(msg) + + +def _check_report_run_simulations( + report: RowMapping, + baseline: RowMapping | None, + reform: RowMapping | None, + new_report_run: NewReportRun, +) -> RowMapping: + """Require one run of each of the report's simulations, and no other. + + Return the baseline run's key, from which the report run takes its catalog + version and year. + """ + expected_reform_run = report["reform_simulation_id"] is not None + if ( + baseline is None + or baseline["simulation_id"] != report["baseline_simulation_id"] + or expected_reform_run != (new_report_run.reform_simulation_run_id is not None) + or ( + expected_reform_run + and ( + reform is None + or reform["simulation_id"] != report["reform_simulation_id"] + ) + ) + ): + msg = ( + "a report run needs one existing run of each of the report's " + "simulations: its baseline and, when it has one, its reform" + ) + raise ValueError(msg) + return baseline + + +class RunStore: + """Typed SQLAlchemy Core reads and inserts for runs.""" + + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime] = utc_now, + ) -> None: + """Use ``engine`` (the runtime role) and ``clock`` for every timestamp.""" + self.engine = engine + self._clock = clock + + # --- Simulation runs --------------------------------------------------- + + def start_simulation_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Return the reusable run for this setup, or create the next attempt. + + A run is reused while it is unfinished, or when it succeeded within + ``SUCCEEDED_RUN_REUSE_WINDOW``; a failed run never is. ``force`` always + adds the next attempt, unless another caller added it at the same + time, in which case that run is returned. Raise LookupError when the + simulation does not exist. + """ + now = self._clock() + with self.engine.begin() as connection: + simulation = ( + connection.execute( + select(simulations.c.kind, simulations.c.region_code).where( + simulations.c.id == new_run.simulation_id + ) + ) + .mappings() + .first() + ) + if simulation is None: + msg = f"no simulation {new_run.simulation_id}" + raise LookupError(msg) + _check_region_code(simulation, new_run) + latest = self._latest_simulation_run( + connection, + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + if latest is not None and not force and self._reusable(latest, now): + return StartedSimulationRun(run=latest, created=False) + attempt = 1 if latest is None else latest.attempt + 1 + created = ( + connection.execute( + postgres_insert(simulation_runs) + .values( + id=uuid4(), + simulation_id=new_run.simulation_id, + simulation_kind=simulation["kind"], + tax_benefit_model_version_id=( + new_run.tax_benefit_model_version_id + ), + year=new_run.year, + attempt=attempt, + region_code=new_run.region_code, + dataset_id=new_run.dataset_id, + trigger=new_run.trigger.value, + status=RunStatus.PENDING.value, + created_at=now, + updated_at=now, + ) + .on_conflict_do_nothing( + index_elements=[ + simulation_runs.c.simulation_id, + simulation_runs.c.tax_benefit_model_version_id, + simulation_runs.c.year, + simulation_runs.c.attempt, + ] + ) + .returning(*simulation_runs.c) + ) + .mappings() + .first() + ) + if created is not None: + return StartedSimulationRun( + run=SimulationRunRecord.from_row(created), created=True + ) + # Another caller added this attempt after the read above; the + # insert waited for it to commit, so it is visible now. + concurrent = self._latest_simulation_run( + connection, + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + if concurrent is None: # pragma: no cover - the conflict proves a row + msg = "the conflicting simulation run disappeared" + raise RuntimeError(msg) + return StartedSimulationRun(run=concurrent, created=False) + + @staticmethod + def _reusable(run: SimulationRunRecord, now: datetime) -> bool: + if run.status in UNFINISHED_STATUSES: + return True + return ( + run.status is RunStatus.SUCCEEDED + and run.completed_at is not None + and run.completed_at >= now - SUCCEEDED_RUN_REUSE_WINDOW + ) + + @staticmethod + def _latest_simulation_run( + connection: Connection, + simulation_id: UUID, + catalog_version_id: UUID, + year: int, + ) -> SimulationRunRecord | None: + row = ( + connection.execute( + select(simulation_runs) + .where( + simulation_runs.c.simulation_id == simulation_id, + simulation_runs.c.tax_benefit_model_version_id + == catalog_version_id, + simulation_runs.c.year == year, + ) + .order_by(simulation_runs.c.attempt.desc()) + .limit(1) + ) + .mappings() + .first() + ) + return None if row is None else SimulationRunRecord.from_row(row) + + def get_simulation_run(self, run_id: UUID) -> SimulationRunRecord | None: + """Return the simulation run with this ID, or None.""" + with self.engine.connect() as connection: + row = ( + connection.execute( + select(simulation_runs).where(simulation_runs.c.id == run_id) + ) + .mappings() + .first() + ) + return None if row is None else SimulationRunRecord.from_row(row) + + def latest_simulation_run( + self, simulation_id: UUID, catalog_version_id: UUID, year: int + ) -> SimulationRunRecord | None: + """Return the newest attempt for this simulation, catalog version and year.""" + with self.engine.connect() as connection: + return self._latest_simulation_run( + connection, simulation_id, catalog_version_id, year + ) + + # --- Report runs ------------------------------------------------------- + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Add a report run joining two existing simulation runs. + + The report run takes its catalog version and year from its baseline + run; the database refuses a reform run of another version or year. + An economy report run starts with a pending comparison step. Raise + LookupError when the report does not exist, and ValueError when the + runs are not runs of the report's simulations. + """ + now = self._clock() + with self.engine.begin() as connection: + report = ( + connection.execute( + select( + reports.c.kind, + reports.c.baseline_simulation_id, + reports.c.reform_simulation_id, + ).where(reports.c.id == new_report_run.report_id) + ) + .mappings() + .first() + ) + if report is None: + msg = f"no report {new_report_run.report_id}" + raise LookupError(msg) + baseline = self._run_key( + connection, new_report_run.baseline_simulation_run_id + ) + reform = ( + None + if new_report_run.reform_simulation_run_id is None + else self._run_key(connection, new_report_run.reform_simulation_run_id) + ) + baseline = _check_report_run_simulations( + report, baseline, reform, new_report_run + ) + economy = report["kind"] == ReportKind.ECONOMY.value + row = ( + connection.execute( + postgres_insert(report_runs) + .values( + id=uuid4(), + report_id=new_report_run.report_id, + report_kind=report["kind"], + tax_benefit_model_version_id=( + baseline["tax_benefit_model_version_id"] + ), + year=baseline["year"], + baseline_simulation_run_id=( + new_report_run.baseline_simulation_run_id + ), + reform_simulation_run_id=( + new_report_run.reform_simulation_run_id + ), + trigger=new_report_run.trigger.value, + comparison_status=( + RunStatus.PENDING.value if economy else None + ), + created_at=now, + updated_at=now, + ) + .returning(*report_runs.c) + ) + .mappings() + .one() + ) + return ReportRunRecord.from_row(row) + + @staticmethod + def _run_key(connection: Connection, run_id: UUID) -> RowMapping | None: + return ( + connection.execute( + select( + simulation_runs.c.simulation_id, + simulation_runs.c.tax_benefit_model_version_id, + simulation_runs.c.year, + ).where(simulation_runs.c.id == run_id) + ) + .mappings() + .first() + ) + + def get_report_run(self, report_run_id: UUID) -> ReportRunRecord | None: + """Return the report run with this ID, or None.""" + return self._one_report_run(report_runs.c.id == report_run_id) + + def latest_report_run(self, report_id: UUID) -> ReportRunRecord | None: + """Return the report's newest report run, whose status is the report's.""" + return self._one_report_run(report_runs.c.report_id == report_id) + + def _one_report_run(self, condition: ColumnElement[bool]) -> ReportRunRecord | None: + with self.engine.connect() as connection: + row = ( + connection.execute( + select(report_runs) + .where(condition) + .order_by(report_runs.c.created_at.desc(), report_runs.c.id.desc()) + .limit(1) + ) + .mappings() + .first() + ) + return None if row is None else ReportRunRecord.from_row(row) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py new file mode 100644 index 000000000..ad7e5f2c2 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/run_transitions.py @@ -0,0 +1,638 @@ +"""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 collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +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" + # The household function returned a typed failure, such as inputs the + # country model does not know; the run's error detail holds it. + HOUSEHOLD_CALCULATION_FAILED = "household_calculation_failed" + # A comparison step whose baseline or reform simulation run failed. + SIMULATION_RUN_FAILED = "simulation_run_failed" + + +@dataclass(frozen=True) +class RunError: + """A failure to record on a run; the message must carry no user data. + + ``detail`` is an optional JSON object stored with the failure for the + run's reader, such as the inputs a household calculation rejected. It may + hold the caller's own inputs, so it is never logged. + """ + + code: RunErrorCode + message: str + detail: Mapping[str, Any] | None = None + + def __post_init__(self) -> None: + """Reject a detail that is not a JSON object.""" + if self.detail is not None and not isinstance(self.detail, Mapping): + msg = "detail must be a JSON object" + raise TypeError(msg) + + def stored_detail(self) -> dict[str, Any] | None: + """Return the detail as the plain dictionary the run tables store.""" + return None if self.detail is None else dict(self.detail) + + +@dataclass(frozen=True) +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), + "error_detail": error.stored_detail(), + "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), + "error_detail": error.stored_detail(), + "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_error_detail": error.stored_detail(), + "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..54e40800e --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/runs.py @@ -0,0 +1,511 @@ +"""``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 failure carries a detail document, and it is a JSON object. + CheckConstraint( + f"{prefix}error_detail IS NULL OR ({status} = 'failed' " + f"AND jsonb_typeof({prefix}error_detail) = 'object')", + name=f"{prefix}error_detail", + ), + # Only a running call is checked, and every running call is due a check. + CheckConstraint( + f"{unless_absent}" + 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), + # What the failure reported beyond its code and message, such as a + # household calculation's rejected inputs; null for most failures. + Column(f"{prefix}error_detail", JSONB(none_as_null=True), nullable=True), + Column( + f"{prefix}check_count", + Integer, + 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 + error_detail: dict[str, Any] | 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_error_detail: dict[str, Any] | None + comparison_check_count: int + comparison_dispatched_at: datetime | None + comparison_next_check_at: datetime | None + comparison_completed_at: datetime | None + created_at: datetime + updated_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> Self: + """Build the record from a selected ``report_runs`` row.""" + values = _record_values(cls, row) + values["report_kind"] = ReportKind(values["report_kind"]) + values["trigger"] = RunTrigger(values["trigger"]) + if values["comparison_status"] is not None: + values["comparison_status"] = RunStatus(values["comparison_status"]) + return cls(**values) + + +@dataclass(frozen=True) +class StartedSimulationRun: + """The simulation run a start resolved to, and whether this call created it.""" + + run: SimulationRunRecord + created: bool diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py new file mode 100644 index 000000000..b1cad0df4 --- /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 = "0005" + +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..f60ed699b --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/simulations.py @@ -0,0 +1,336 @@ +"""``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. Its ``options`` hold whatever else +changes what it calculates, such as a household's axes; they are empty unless +set. + +Foreign keys into the Public API's tables (``public_api``) tie the policy and +the household to the simulation's country, and name an optional dynamic. The +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 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, + String, + 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 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), + # What else changes the calculation, such as a household's axes; part of + # what a simulation is, so runs are reused only for the same options. + Column("options", JSONB, nullable=False, server_default=text("'{}'::jsonb")), + Column("legacy_simulation_id", BigInteger, nullable=True, unique=True), + Column( + "created_at", + 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", + ), + CheckConstraint("jsonb_typeof(options) = 'object'", name="options"), + # Reports refer to a simulation together with its country and kind, so a + # report can only compare simulations of its own country and kind. + UniqueConstraint("id", "country", "kind"), + # 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_options(options: object) -> None: + """Reject options that are not a JSON object.""" + if not isinstance(options, dict): + msg = "options must be a JSON object" + raise TypeError(msg) + + +def check_country(country: str) -> None: + """Reject a country that is not a lowercase two-letter code.""" + if not COUNTRY_PATTERN.fullmatch(country): + 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 + options: dict[str, Any] = dataclass_field(default_factory=dict) + + 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) + check_options(self.options) + + +@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 + options: dict[str, Any] + created_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> SimulationRecord: + """Build the record from a selected ``simulations`` row.""" + values = {field.name: row[field.name] for field in fields(cls)} + values["kind"] = SimulationKind(values["kind"]) + return cls(**values) + + +def _new_simulation_values(new_simulation: NewSimulation) -> dict[str, Any]: + return { + "id": uuid4(), + **{ + field.name: getattr(new_simulation, field.name) + for field in fields(new_simulation) + }, + "kind": new_simulation.kind.value, + } + + +class SimulationStore: + """Typed SQLAlchemy Core reads and inserts for simulation rows.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + def create(self, new_simulation: NewSimulation) -> SimulationRecord: + """Insert one simulation and return it.""" + with self.engine.begin() as connection: + row = ( + connection.execute( + postgres_insert(simulations) + .values(**_new_simulation_values(new_simulation)) + .returning(*simulations.c) + ) + .mappings() + .one() + ) + return SimulationRecord.from_row(row) + + def create_migrated(self, new_simulation: NewSimulation) -> SimulationRecord: + """Create a simulation for a v1 simulation, or return the existing one. + + The v1 simulation ID must be set. An existing row is returned as it is, + even if ``new_simulation`` describes it differently, so running the + one-time migration again creates nothing new. + """ + if new_simulation.legacy_simulation_id is None: + msg = "a migrated simulation needs its legacy_simulation_id" + raise ValueError(msg) + with self.engine.begin() as connection: + row = insert_or_get_existing( + connection, + simulations, + _new_simulation_values(new_simulation), + simulations.c.legacy_simulation_id, + ) + return SimulationRecord.from_row(row) + + def get(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + return self._one(simulations.c.id == simulation_id) + + def get_by_legacy_simulation_id( + self, legacy_simulation_id: int + ) -> SimulationRecord | None: + """Return the simulation migrated from this v1 simulation, or None.""" + return self._one(simulations.c.legacy_simulation_id == legacy_simulation_id) + + def _one(self, condition: ColumnElement[bool]) -> SimulationRecord | None: + with self.engine.connect() as connection: + row = ( + connection.execute(select(simulations).where(condition)) + .mappings() + .first() + ) + return None if row is None else SimulationRecord.from_row(row) diff --git a/libs/policyengine-simulation-persistence/tests/conftest.py b/libs/policyengine-simulation-persistence/tests/conftest.py 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/fixtures/public_api/household_read_columns.json b/libs/policyengine-simulation-persistence/tests/fixtures/public_api/household_read_columns.json new file mode 100644 index 000000000..deb750634 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/fixtures/public_api/household_read_columns.json @@ -0,0 +1,36 @@ +{ + "source": "information_schema.columns of a database migrated by the Public API's v2 Alembic chain at revision aeb5f3e8f662", + "tables": { + "households": { + "id": {"type": "uuid", "nullable": false}, + "country_id": {"type": "varchar", "length": 2, "nullable": false}, + "default_year": {"type": "int4", "nullable": true}, + "household_data": {"type": "jsonb", "nullable": false} + }, + "policies": { + "id": {"type": "uuid", "nullable": false}, + "country_id": {"type": "varchar", "length": 2, "nullable": false} + }, + "parameters": { + "id": {"type": "uuid", "nullable": false}, + "name": {"type": "varchar", "length": 512, "nullable": false}, + "tax_benefit_model_version_id": {"type": "uuid", "nullable": false} + }, + "parameter_values": { + "parameter_id": {"type": "uuid", "nullable": false}, + "value_json": {"type": "jsonb", "nullable": false}, + "start_date": {"type": "timestamptz", "nullable": false}, + "end_date": {"type": "timestamptz", "nullable": true}, + "policy_id": {"type": "uuid", "nullable": true} + }, + "tax_benefit_models": { + "id": {"type": "uuid", "nullable": false}, + "name": {"type": "varchar", "length": 32, "nullable": false} + }, + "tax_benefit_model_versions": { + "id": {"type": "uuid", "nullable": false}, + "model_id": {"type": "uuid", "nullable": false}, + "version": {"type": "varchar", "length": 128, "nullable": false} + } + } +} diff --git a/libs/policyengine-simulation-persistence/tests/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_support.py b/libs/policyengine-simulation-persistence/tests/public_api_support.py new file mode 100644 index 000000000..62479cfdf --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/public_api_support.py @@ -0,0 +1,268 @@ +"""The Public API tables the simulation schema refers to or the household path reads. + +In production these tables belong to the Public API's own migration chain, in +the same database as the simulation schema. The tests create only the columns +and constraints the simulation schema's foreign keys and the household reads +use, with the Public API's constraint names, owned by the superuser, and +grant the migrator ``REFERENCES`` on them, as the operator does in +production. Every column the household reads select has the type and +nullability the Public API's migrations give it +(``test_public_api_copies``): ``households.household_data`` and +``parameter_values.value_json`` are ``jsonb``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from migration_support import MIGRATOR_ROLE +from policyengine_simulation_persistence import HOUSEHOLD_READ_TABLES, RUNTIME_ROLE +from sqlalchemy import create_engine, text + +if TYPE_CHECKING: + from sqlalchemy import Engine + +PUBLIC_API_TABLE_STATEMENTS = ( + "CREATE TABLE public.tax_benefit_models (" + " id uuid PRIMARY KEY," + " name varchar(32) NOT NULL," + " CONSTRAINT uq_tax_benefit_models_name UNIQUE (name))", + "CREATE TABLE public.tax_benefit_model_versions (" + " id uuid PRIMARY KEY," + " model_id uuid NOT NULL REFERENCES public.tax_benefit_models (id)," + " version varchar(128) NOT NULL," + " CONSTRAINT uq_tax_benefit_model_versions_model_version" + " UNIQUE (model_id, version))", + "CREATE TABLE public.datasets (" + " id uuid PRIMARY KEY," + " name varchar(255) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " CONSTRAINT uq_datasets_model_version_name" + " UNIQUE (tax_benefit_model_version_id, name)," + " CONSTRAINT uq_datasets_id_model_version" + " UNIQUE (id, tax_benefit_model_version_id))", + "CREATE TABLE public.regions (" + " id uuid PRIMARY KEY," + " code varchar(255) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " default_dataset_id uuid NOT NULL," + " CONSTRAINT uq_regions_model_version_code" + " UNIQUE (tax_benefit_model_version_id, code))", + "CREATE TABLE public.policies (" + " id uuid PRIMARY KEY," + " country_id varchar(2) NOT NULL," + " CONSTRAINT uq_policies_id_country UNIQUE (id, country_id))", + "CREATE TABLE public.households (" + " id uuid PRIMARY KEY," + " country_id varchar(2) NOT NULL," + " default_year integer," + " household_data jsonb NOT NULL," + " CONSTRAINT uq_households_id_country UNIQUE (id, country_id))", + "CREATE TABLE public.dynamics (id uuid PRIMARY KEY)", + "CREATE TABLE public.parameters (" + " id uuid PRIMARY KEY," + " name varchar(512) NOT NULL," + " tax_benefit_model_version_id uuid NOT NULL" + " REFERENCES public.tax_benefit_model_versions (id)," + " CONSTRAINT uq_parameters_model_version_name" + " UNIQUE (tax_benefit_model_version_id, name))", + "CREATE TABLE public.parameter_values (" + " id uuid PRIMARY KEY," + " parameter_id uuid NOT NULL REFERENCES public.parameters (id)," + " value_json jsonb NOT NULL," + " start_date timestamptz NOT NULL," + " end_date timestamptz," + " policy_id uuid REFERENCES public.policies (id)," + " dynamic_id uuid REFERENCES public.dynamics (id)," + " CONSTRAINT ck_parameter_values_effective_period" + " CHECK (end_date IS NULL OR end_date >= start_date)," + " CONSTRAINT ck_parameter_values_single_owner" + " CHECK (policy_id IS NULL OR dynamic_id IS NULL)," + " CONSTRAINT uq_parameter_values_policy_parameter_start_date" + " UNIQUE (policy_id, parameter_id, start_date))", +) +# The catalog versions' PolicyEngine.py versions, oldest first. +CATALOG_POLICYENGINE_VERSION = "6.9.0" +NEWER_CATALOG_POLICYENGINE_VERSION = "6.10.0" +# A stored household document; these rows are only referred to, never read. +EMPTY_HOUSEHOLD_DOCUMENT = '{"people": []}' +REFERENCED_TABLES = ( + "tax_benefit_model_versions", + "datasets", + "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 tables a household run is calculated from + (``HOUSEHOLD_READ_TABLES``), as the operator grants in production. + """ + engine = superuser_engine(superuser_url, isolation_level="AUTOCOMMIT") + grant = ( + "GRANT REFERENCES ON " + + ", ".join(f"public.{table}" for table in REFERENCED_TABLES) + + f" TO {MIGRATOR_ROLE}" + ) + read_grant = ( + "GRANT SELECT ON " + + ", ".join(f"public.{table}" for table in HOUSEHOLD_READ_TABLES) + + f" TO {RUNTIME_ROLE}" + ) + statements = ( + (*PUBLIC_API_TABLE_STATEMENTS, grant, 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 US catalog versions (PolicyEngine.py ``CATALOG_POLICYENGINE_VERSION`` + and ``NEWER_CATALOG_POLICYENGINE_VERSION``) each have California + (``state/ca``) with its own default dataset, as each publication creates + new rows. + """ + + us_model_id: UUID + uk_model_id: UUID + us_policy_id: UUID + uk_policy_id: UUID + us_household_id: UUID + uk_household_id: UUID + dynamic_id: UUID + catalog_version_id: UUID + newer_catalog_version_id: UUID + dataset_id: UUID + newer_dataset_id: UUID + + +def _catalog_statements(rows: PublicApiRows) -> tuple[tuple[str, dict], ...]: + return ( + ( + "INSERT INTO public.tax_benefit_models (id, name) VALUES " + "(:us_model, 'policyengine-us'), (:uk_model, 'policyengine-uk')", + {"us_model": rows.us_model_id, "uk_model": rows.uk_model_id}, + ), + ( + "INSERT INTO public.tax_benefit_model_versions (id, model_id, version) " + "VALUES (:version, :us_model, :policyengine_version), " + "(:newer_version, :us_model, :newer_policyengine_version)", + { + "version": rows.catalog_version_id, + "newer_version": rows.newer_catalog_version_id, + "us_model": rows.us_model_id, + "policyengine_version": CATALOG_POLICYENGINE_VERSION, + "newer_policyengine_version": NEWER_CATALOG_POLICYENGINE_VERSION, + }, + ), + ( + "INSERT INTO public.datasets (id, name, tax_benefit_model_version_id) " + "VALUES (:dataset, 'populace_us_2024', :version), " + "(:newer_dataset, 'populace_us_2024_acs_local', :newer_version)", + { + "dataset": rows.dataset_id, + "newer_dataset": rows.newer_dataset_id, + "version": rows.catalog_version_id, + "newer_version": rows.newer_catalog_version_id, + }, + ), + ( + "INSERT INTO public.regions " + "(id, code, tax_benefit_model_version_id, default_dataset_id) VALUES " + "(gen_random_uuid(), 'state/ca', :version, :dataset), " + "(gen_random_uuid(), 'state/ca', :newer_version, :newer_dataset)", + { + "version": rows.catalog_version_id, + "newer_version": rows.newer_catalog_version_id, + "dataset": rows.dataset_id, + "newer_dataset": rows.newer_dataset_id, + }, + ), + ) + + +def insert_public_api_rows(superuser_url: str) -> PublicApiRows: + """Insert rows into the referenced Public API tables, as their owner.""" + rows = PublicApiRows( + us_model_id=uuid4(), + uk_model_id=uuid4(), + us_policy_id=uuid4(), + uk_policy_id=uuid4(), + us_household_id=uuid4(), + uk_household_id=uuid4(), + dynamic_id=uuid4(), + catalog_version_id=uuid4(), + newer_catalog_version_id=uuid4(), + dataset_id=uuid4(), + newer_dataset_id=uuid4(), + ) + statements = ( + *_catalog_statements(rows), + ( + "INSERT INTO public.policies (id, country_id) VALUES " + "(:us_policy, 'us'), (:uk_policy, 'uk')", + {"us_policy": rows.us_policy_id, "uk_policy": rows.uk_policy_id}, + ), + ( + "INSERT INTO public.households " + "(id, country_id, default_year, household_data) VALUES " + "(:us_household, 'us', 2026, :document), " + "(:uk_household, 'uk', 2026, :document)", + { + "us_household": rows.us_household_id, + "uk_household": rows.uk_household_id, + "document": EMPTY_HOUSEHOLD_DOCUMENT, + }, + ), + ( + "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_definitions.py b/libs/policyengine-simulation-persistence/tests/test_definitions.py new file mode 100644 index 000000000..f723dadc4 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_definitions.py @@ -0,0 +1,110 @@ +"""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_simulation_options_must_be_a_json_object() -> None: + with pytest.raises(TypeError, match="options must be a JSON object"): + NewSimulation( + kind=SimulationKind.HOUSEHOLD, + country="us", + household_id=uuid4(), + options=["add_missing_variables"], # type: ignore[arg-type] + ) + + +def test_a_kind_given_as_text_becomes_the_enum() -> None: + simulation = NewSimulation(kind="economy", country="us", region_code="state/ca") # type: ignore[arg-type] + report = NewReport( + 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_household_calculations.py b/libs/policyengine-simulation-persistence/tests/test_household_calculations.py new file mode 100644 index 000000000..80b2b4cb4 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_calculations.py @@ -0,0 +1,279 @@ +"""Household calculations: simulations found by what they calculate, and read back.""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + HouseholdCalculationStore, + HouseholdOutput, + RunError, + RunErrorCode, + RunStatus, + create_simulation_engine, + write_household_output, +) +from public_api_support import superuser_engine +from run_support import economy_run, household_run +from simulation_support import ( + economy_simulation, + household_simulation, + simulation_store, +) +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from transition_support import ManualClock, clocked_stores + +pytestmark = pytest.mark.postgres + +EARNINGS_AXES = [ + [{"name": "employment_income", "count": 3, "min": 0, "max": 20_000}], +] + + +def household_store(database) -> HouseholdCalculationStore: + """Return a household calculation store that connects as the runtime role.""" + return HouseholdCalculationStore(create_simulation_engine(database.runtime_url)) + + +def test_the_same_household_policy_and_options_find_one_simulation( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + definition = household_simulation( + public_api_rows, + policy_id=public_api_rows.us_policy_id, + options={"add_missing_variables": True}, + ) + + created = store.find_or_create_simulation(definition) + found = store.find_or_create_simulation(definition) + + assert found.id == created.id + assert created.options == {"add_missing_variables": True} + assert created.policy_id == public_api_rows.us_policy_id + + +def test_another_policy_household_dynamic_or_options_is_another_simulation( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + current_law = store.find_or_create_simulation(household_simulation(public_api_rows)) + variants = [ + household_simulation(public_api_rows, policy_id=public_api_rows.us_policy_id), + household_simulation(public_api_rows, dynamic_id=public_api_rows.dynamic_id), + household_simulation(public_api_rows, options={"axes": EARNINGS_AXES}), + household_simulation( + public_api_rows, country="uk", household_id=public_api_rows.uk_household_id + ), + ] + + variant_ids = {store.find_or_create_simulation(variant).id for variant in variants} + + assert len(variant_ids) == len(variants) + assert current_law.id not in variant_ids + found_again = store.find_or_create_simulation(household_simulation(public_api_rows)) + assert found_again.id == current_law.id + + +def test_options_match_by_value_whatever_their_key_order( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + + first = store.find_or_create_simulation( + household_simulation( + public_api_rows, + options={"axes": EARNINGS_AXES, "add_missing_variables": False}, + ) + ) + second = store.find_or_create_simulation( + household_simulation( + public_api_rows, + options={"add_missing_variables": False, "axes": EARNINGS_AXES}, + ) + ) + + assert second.id == first.id + + +def test_a_simulation_created_without_options_has_empty_options( + migrated_database, public_api_rows +) -> None: + created = simulation_store(migrated_database).create( + household_simulation(public_api_rows) + ) + + found = household_store(migrated_database).find_or_create_simulation( + household_simulation(public_api_rows) + ) + + assert created.options == {} + assert found.id == created.id + + +def test_only_a_household_simulation_is_found_by_its_household( + migrated_database, public_api_rows +) -> None: + with pytest.raises(ValueError, match="household simulation"): + household_store(migrated_database).find_or_create_simulation( + economy_simulation(public_api_rows) + ) + + +def test_the_database_refuses_options_that_are_not_an_object( + migrated_database, public_api_rows +) -> None: + created = simulation_store(migrated_database).create( + household_simulation(public_api_rows) + ) + engine = superuser_engine(migrated_database.superuser_url) + try: + with ( + pytest.raises(IntegrityError, match="ck_simulations_options"), + engine.begin() as connection, + ): + connection.execute( + text( + "UPDATE simulation.simulations SET options = '[]'::jsonb " + "WHERE id = :simulation_id" + ), + {"simulation_id": created.id}, + ) + finally: + engine.dispose() + + +def clocked_household_store(database, clock: ManualClock) -> HouseholdCalculationStore: + """Return a household calculation store whose runs use ``clock``.""" + return HouseholdCalculationStore( + create_simulation_engine(database.runtime_url), clock=clock + ) + + +def _calculation_run(store, rows, **overrides): + simulation = store.find_or_create_simulation(household_simulation(rows)) + return store.start_run(household_run(simulation, rows, **overrides)) + + +def test_a_submitted_calculation_reads_back_pending_without_output( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + started = _calculation_run(store, public_api_rows) + + calculation = store.read_calculation(started.run.id) + + assert started.created + assert calculation.run.id == started.run.id + assert calculation.run.status is RunStatus.PENDING + assert calculation.simulation.household_id == public_api_rows.us_household_id + assert calculation.output is None + + +def test_a_calculation_submitted_again_reuses_its_unfinished_run( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + first = _calculation_run(store, public_api_rows) + + again = _calculation_run(store, public_api_rows) + + assert again.run.id == first.run.id + assert not again.created + + +def test_a_succeeded_calculation_reads_back_with_its_household( + migrated_database, public_api_rows +) -> None: + clock = ManualClock() + store = clocked_household_store(migrated_database, clock) + transitions = clocked_stores(migrated_database, clock).transitions + run = _calculation_run(store, public_api_rows).run + transitions.claim_simulation_dispatch([run.id]) + transitions.record_simulation_dispatched( + [run.id], modal_call_id="fc-calculation", first_check_at=clock() + ) + output = HouseholdOutput( + schema_version=1, + household={"people": {"you": {"age": {"2026": 40}}}}, + warnings=("A warning.",), + ) + transitions.complete_simulation_call( + "fc-calculation", + lambda connection, runs: write_household_output(connection, runs[0], output), + ) + + calculation = store.read_calculation(run.id) + + assert calculation.run.status is RunStatus.SUCCEEDED + assert calculation.output == output + + +def test_a_rejected_calculation_reads_back_with_its_error_detail( + migrated_database, public_api_rows +) -> None: + clock = ManualClock() + store = clocked_household_store(migrated_database, clock) + transitions = clocked_stores(migrated_database, clock).transitions + run = _calculation_run(store, public_api_rows).run + transitions.claim_simulation_dispatch([run.id]) + transitions.record_simulation_dispatched( + [run.id], modal_call_id="fc-rejected", first_check_at=clock() + ) + transitions.fail_simulation_call( + "fc-rejected", + RunError( + RunErrorCode.HOUSEHOLD_CALCULATION_FAILED, + "The household calculation failed: invalid_inputs.", + detail={"code": "invalid_inputs", "message": "Unrecognized inputs"}, + ), + ) + + calculation = store.read_calculation(run.id) + + assert calculation.run.status is RunStatus.FAILED + assert calculation.run.error_code == "household_calculation_failed" + assert calculation.run.error_detail == { + "code": "invalid_inputs", + "message": "Unrecognized inputs", + } + assert calculation.output is None + + +def test_a_calculation_left_dispatching_fails_and_can_be_submitted_again( + migrated_database, public_api_rows +) -> None: + """The instance that claimed the run stopped before recording its call.""" + clock = ManualClock() + store = clocked_household_store(migrated_database, clock) + transitions = clocked_stores(migrated_database, clock).transitions + run = _calculation_run(store, public_api_rows).run + assert transitions.claim_simulation_dispatch([run.id]) + + clock.advance(timedelta(minutes=11)) + transitions.fail_abandoned_dispatches( + claimed_before=clock() - timedelta(minutes=10) + ) + + abandoned = store.read_calculation(run.id) + assert abandoned.run.status is RunStatus.FAILED + assert abandoned.run.error_code == "dispatch_abandoned" + resubmitted = _calculation_run(store, public_api_rows) + assert resubmitted.created + assert resubmitted.run.attempt == 2 + + +def test_only_a_household_run_reads_as_a_calculation( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + economy = simulation_store(migrated_database).create( + economy_simulation(public_api_rows) + ) + economy_run_record = store.start_run(economy_run(economy, public_api_rows)).run + + assert store.read_calculation(economy_run_record.id) is None + assert store.read_calculation(uuid4()) is None diff --git a/libs/policyengine-simulation-persistence/tests/test_household_reads.py b/libs/policyengine-simulation-persistence/tests/test_household_reads.py new file mode 100644 index 000000000..ba2af05dc --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_reads.py @@ -0,0 +1,270 @@ +"""Reading the Public API's household, policy and catalog rows a household run needs.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest +from policyengine_simulation_persistence import ( + HouseholdInputReader, + PolicyParametersMissingError, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, + create_simulation_engine, +) +from public_api_support import superuser_engine +from sqlalchemy import create_engine, text + +pytestmark = pytest.mark.postgres + +CTC_AMOUNT = "gov.irs.credits.ctc.amount.base[0].amount" +STANDARD_DEDUCTION = "gov.irs.deductions.standard.amount.SINGLE" +RETIRED_PARAMETER = "gov.irs.credits.retired_credit.amount" +US_DOCUMENT = { + "people": [ + { + "id": "person-1", + "values": {"age": {"2025": 40}}, + "memberships": {"household": "household-1"}, + } + ], + "household": [{"id": "household-1", "values": {"state_name": {"2025": "CA"}}}], + "spm": {"scenario": "ce_trend"}, +} + + +@dataclass(frozen=True) +class HouseholdRows: + """The identifiers of the rows each test reads.""" + + older_version_id: UUID + version_id: UUID + uk_version_id: UUID + household_id: UUID + uk_household_id: UUID + policy_id: UUID + retired_policy_id: UUID + empty_policy_id: UUID + + +def _insert_rows(superuser_url: str) -> HouseholdRows: + rows = HouseholdRows( + older_version_id=uuid4(), + version_id=uuid4(), + uk_version_id=uuid4(), + household_id=uuid4(), + uk_household_id=uuid4(), + policy_id=uuid4(), + retired_policy_id=uuid4(), + empty_policy_id=uuid4(), + ) + us_model, uk_model = uuid4(), uuid4() + older_parameters = {name: uuid4() for name in (CTC_AMOUNT, RETIRED_PARAMETER)} + statements: list[tuple[str, dict[str, object]]] = [ + ( + "INSERT INTO public.tax_benefit_models (id, name) VALUES " + "(:us, 'policyengine-us'), (:uk, 'policyengine-uk')", + {"us": us_model, "uk": uk_model}, + ), + ( + "INSERT INTO public.tax_benefit_model_versions (id, model_id, version) " + "VALUES (:older, :us, '6.5.0'), (:version, :us, '6.7.5'), " + "(:uk_version, :uk, '6.7.5')", + { + "older": rows.older_version_id, + "version": rows.version_id, + "uk_version": rows.uk_version_id, + "us": us_model, + "uk": uk_model, + }, + ), + *( + ( + "INSERT INTO public.parameters (id, name, " + "tax_benefit_model_version_id) VALUES (:id, :name, :version)", + {"id": parameter_id, "name": name, "version": rows.older_version_id}, + ) + for name, parameter_id in older_parameters.items() + ), + *( + ( + "INSERT INTO public.parameters (id, name, " + "tax_benefit_model_version_id) VALUES (gen_random_uuid(), :name, " + ":version)", + {"name": name, "version": rows.version_id}, + ) + for name in (CTC_AMOUNT, STANDARD_DEDUCTION) + ), + ( + "INSERT INTO public.policies (id, country_id) VALUES " + "(:policy, 'us'), (:retired, 'us'), (:empty, 'us')", + { + "policy": rows.policy_id, + "retired": rows.retired_policy_id, + "empty": rows.empty_policy_id, + }, + ), + ( + "INSERT INTO public.parameter_values (id, parameter_id, value_json, " + "start_date, end_date, policy_id) VALUES " + "(gen_random_uuid(), :ctc, '3000', '2026-01-01T00:00:00Z', NULL, " + ":policy), " + "(gen_random_uuid(), :ctc, '2500', '2025-01-01T00:00:00Z', " + "'2025-12-31T00:00:00Z', :policy), " + "(gen_random_uuid(), :retired_parameter, 'true', " + "'2026-01-01T00:00:00Z', NULL, :retired)", + { + "ctc": older_parameters[CTC_AMOUNT], + "retired_parameter": older_parameters[RETIRED_PARAMETER], + "policy": rows.policy_id, + "retired": rows.retired_policy_id, + }, + ), + ( + "INSERT INTO public.households (id, country_id, default_year, " + "household_data) VALUES (:household, 'us', 2025, :document), " + "(:uk_household, 'uk', NULL, :uk_document)", + { + "household": rows.household_id, + "uk_household": rows.uk_household_id, + "document": json.dumps(US_DOCUMENT), + "uk_document": json.dumps({"people": [], "benunit": []}), + }, + ), + ] + engine = superuser_engine(superuser_url) + try: + with engine.begin() as connection: + for statement, parameters in statements: + connection.execute(text(statement), parameters) + finally: + engine.dispose() + return rows + + +@pytest.fixture +def household_rows(migrated_database) -> HouseholdRows: + """Insert the catalog, policy and household rows the tests read.""" + return _insert_rows(migrated_database.superuser_url) + + +@pytest.fixture +def reader(migrated_database) -> HouseholdInputReader: + """Return a reader that connects as the runtime role.""" + return HouseholdInputReader(create_simulation_engine(migrated_database.runtime_url)) + + +def test_a_catalog_version_is_found_by_country_and_policyengine_version( + reader, household_rows +) -> None: + version = reader.catalog_version("us", "6.7.5") + + assert version.id == household_rows.version_id + assert version.country == "us" + assert version.policyengine_version == "6.7.5" + assert reader.catalog_version("uk", "6.7.5").id == household_rows.uk_version_id + + +@pytest.mark.parametrize( + ("country", "policyengine_version"), [("us", "9.9.9"), ("uk", "6.5.0")] +) +def test_an_unpublished_catalog_version_is_unknown( + reader, household_rows, country, policyengine_version +) -> None: + with pytest.raises(UnknownCatalogVersionError, match=policyengine_version): + reader.catalog_version(country, policyengine_version) + + +def test_a_household_is_read_with_its_document_and_default_year( + reader, household_rows +) -> None: + household = reader.household("us", household_rows.household_id) + + assert household.id == household_rows.household_id + assert household.country == "us" + assert household.default_year == 2025 + assert household.document == US_DOCUMENT + assert reader.household("uk", household_rows.uk_household_id).default_year is None + + +@pytest.mark.parametrize("which", ["another-country", "missing"]) +def test_a_household_of_another_country_or_none_is_unknown( + reader, household_rows, which +) -> None: + household_id = ( + household_rows.uk_household_id if which == "another-country" else uuid4() + ) + + with pytest.raises(UnknownHouseholdError, match=str(household_id)): + reader.household("us", household_id) + + +def test_a_policys_values_are_read_by_name_on_the_runs_version( + reader, household_rows +) -> None: + # The policy was saved on 6.5.0's parameters; 6.7.5 has the same name. + parameters = reader.policy_parameters( + "us", household_rows.policy_id, household_rows.version_id + ) + + assert parameters == { + CTC_AMOUNT: { + "2025-01-01.2025-12-31": 2500, + "2026-01-01.2100-12-31": 3000, + } + } + + +def test_a_policys_periods_are_read_in_utc_whatever_the_session_time_zone( + migrated_database, household_rows +) -> None: + engine = create_engine( + migrated_database.runtime_url.replace( + "postgresql://", "postgresql+psycopg://", 1 + ), + connect_args={"options": "-c timezone=Pacific/Honolulu"}, + ) + try: + parameters = HouseholdInputReader(engine).policy_parameters( + "us", household_rows.policy_id, household_rows.version_id + ) + finally: + engine.dispose() + + assert set(parameters[CTC_AMOUNT]) == { + "2025-01-01.2025-12-31", + "2026-01-01.2100-12-31", + } + + +def test_a_policy_changing_no_parameter_changes_nothing(reader, household_rows) -> None: + assert ( + reader.policy_parameters( + "us", household_rows.empty_policy_id, household_rows.version_id + ) + == {} + ) + + +def test_a_parameter_the_runs_version_lacks_is_refused_by_name( + reader, household_rows +) -> None: + with pytest.raises(PolicyParametersMissingError, match=RETIRED_PARAMETER) as error: + reader.policy_parameters( + "us", household_rows.retired_policy_id, household_rows.version_id + ) + + assert error.value.names == (RETIRED_PARAMETER,) + + +@pytest.mark.parametrize("country", ["uk", "us"]) +def test_a_policy_of_another_country_or_none_is_unknown( + reader, household_rows, country +) -> None: + policy_id = household_rows.policy_id if country == "uk" else uuid4() + + with pytest.raises(UnknownPolicyError, match=str(policy_id)): + reader.policy_parameters(country, policy_id, household_rows.version_id) diff --git a/libs/policyengine-simulation-persistence/tests/test_household_reports.py b/libs/policyengine-simulation-persistence/tests/test_household_reports.py new file mode 100644 index 000000000..be002f7a7 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_reports.py @@ -0,0 +1,153 @@ +"""Household reports: two household simulations' runs, sharing one Modal call.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + HouseholdCalculationStore, + NewReport, + NewReportRun, + ReportKind, + RunStatus, + RunTrigger, + create_simulation_engine, +) +from report_support import economy_pair, economy_report +from run_support import household_run +from simulation_support import household_simulation + +pytestmark = pytest.mark.postgres + + +def household_store(database) -> HouseholdCalculationStore: + """Return a household calculation store that connects as the runtime role.""" + return HouseholdCalculationStore(create_simulation_engine(database.runtime_url)) + + +def _report_with_run(store, rows, *, reform_policy_id=None, trigger=None): + """Create a 2026 household report and one run of it; return both.""" + baseline = store.find_or_create_simulation(household_simulation(rows)) + reform = ( + None + if reform_policy_id is None + else store.find_or_create_simulation( + household_simulation(rows, policy_id=reform_policy_id) + ) + ) + report = store.create_report( + NewReport( + kind=ReportKind.HOUSEHOLD, + country="us", + baseline_simulation_id=baseline.id, + reform_simulation_id=None if reform is None else reform.id, + year=2026, + ) + ) + baseline_run = store.start_run(household_run(baseline, rows)).run + reform_run = ( + None if reform is None else store.start_run(household_run(reform, rows)).run + ) + report_run = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=None if reform_run is None else reform_run.id, + trigger=trigger or RunTrigger.INITIAL, + ) + ) + return report, report_run + + +def test_a_household_report_reads_back_with_both_calculations( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, report_run = _report_with_run( + store, public_api_rows, reform_policy_id=public_api_rows.us_policy_id + ) + + read = store.read_report(report.id) + + assert read.report.id == report.id + assert read.report_run.id == report_run.id + assert read.baseline.run.id == report_run.baseline_simulation_run_id + assert read.baseline.simulation.policy_id is None + assert read.reform.run.id == report_run.reform_simulation_run_id + assert read.reform.simulation.policy_id == public_api_rows.us_policy_id + assert read.baseline.run.status is RunStatus.PENDING + assert read.baseline.output is None + + +def test_a_baseline_only_household_report_has_no_reform( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, _ = _report_with_run(store, public_api_rows) + + read = store.read_report(report.id) + + assert read.report.reform_simulation_id is None + assert read.reform is None + + +def test_a_report_reads_its_newest_run_unless_another_is_named( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, first_run = _report_with_run(store, public_api_rows) + second_run = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=first_run.baseline_simulation_run_id, + trigger=RunTrigger.MANUAL, + ) + ) + + assert store.read_report(report.id).report_run.id == second_run.id + assert ( + store.read_report(report.id, report_run_id=first_run.id).report_run.id + == first_run.id + ) + + +def test_a_report_without_runs_reads_back_without_calculations( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + baseline = store.find_or_create_simulation(household_simulation(public_api_rows)) + report = store.create_report( + NewReport( + kind=ReportKind.HOUSEHOLD, + country="us", + baseline_simulation_id=baseline.id, + year=2026, + ) + ) + + read = store.read_report(report.id) + + assert read.report_run is None + assert read.baseline is None + assert read.reform is None + assert store.get_report(report.id) == report + assert store.get_simulation(baseline.id) == baseline + + +def test_only_household_reports_and_their_own_runs_are_read( + migrated_database, public_api_rows +) -> None: + store = household_store(migrated_database) + report, _ = _report_with_run(store, public_api_rows) + other_report, other_run = _report_with_run( + store, public_api_rows, reform_policy_id=public_api_rows.us_policy_id + ) + baseline, reform = economy_pair(migrated_database, public_api_rows) + economy = store.create_report(economy_report(baseline, reform)) + + assert store.read_report(economy.id) is None + assert store.get_report(economy.id) is None + assert store.read_report(uuid4()) is None + assert store.read_report(report.id, report_run_id=other_run.id) is None + assert store.read_report(other_report.id, report_run_id=other_run.id) is not None 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..e7143d55b --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_household_results.py @@ -0,0 +1,193 @@ +"""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"]}) + + +SPM_CONFIG = { + "forecast_scenario": "central", + "geography": "national", + "information_date": "2026-01-01", +} +SPM_PROVENANCE = { + "status": "certified", + "forecast": {"release": "2026-09", "years": [2026]}, +} +BUNDLE = { + "policyengine_version": "6.10.0", + "core_package_version": "3.20.0", + "country_package_name": "policyengine-us", + "country_package_version": "1.450.0", + "bundle_manifest_sha256": "a" * 64, +} + + +def test_a_household_is_stored_with_its_warnings_spm_choice_and_bundle( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + runs = _running_household_pair(stores, migrated_database, public_api_rows) + output = _sample().model_copy( + update={ + "warnings": ("The input age_group is deprecated and was ignored.",), + "spm_config": SPM_CONFIG, + "spm_provenance": SPM_PROVENANCE, + "bundle": BUNDLE, + } + ) + + def write_both(connection, succeeded_runs) -> None: + for run in succeeded_runs: + write_household_output(connection, run, output) + + assert stores.transitions.complete_simulation_call("fc-household", write_both) + + with stores.transitions.engine.connect() as connection: + stored = read_household_output(connection, runs[0].id) + assert stored == output + assert stored.warnings == ("The input age_group is deprecated and was ignored.",) + assert stored.spm_config == SPM_CONFIG + assert stored.spm_provenance == SPM_PROVENANCE + assert stored.bundle == BUNDLE + + +def test_a_household_without_warnings_or_spm_reads_back_without_them( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + runs = _running_household_pair(stores, migrated_database, public_api_rows) + + def write_both(connection, succeeded_runs) -> None: + for run in succeeded_runs: + write_household_output(connection, run, _sample()) + + stores.transitions.complete_simulation_call("fc-household", write_both) + + with stores.transitions.engine.connect() as connection: + stored = read_household_output(connection, runs[1].id) + assert stored.warnings == () + assert stored.spm_config is None + assert stored.spm_provenance is None + assert stored.bundle is None + + +def test_household_output_documents_must_be_json_objects() -> None: + with pytest.raises(ValueError, match=r"spm_config\n.*valid dictionary"): + HouseholdOutput.model_validate( + {"schema_version": 1, "household": {}, "spm_config": ["central"]} + ) + with pytest.raises(ValueError, match=r"warnings\.0\n.*valid string"): + HouseholdOutput.model_validate( + {"schema_version": 1, "household": {}, "warnings": [{"text": "x"}]} + ) diff --git a/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_copies.py b/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py new file mode 100644 index 000000000..21c3810ff --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_public_api_copies.py @@ -0,0 +1,87 @@ +"""The tests' copies of the Public API tables match the Public API's columns. + +The household reads (``household_reads``) select columns of the Public API's +tables. The tests create copies of those tables (``public_api_support``); a +copy whose column has another type or nullability than the real one would +let a read pass here and fail in production, as a text comparison with a +PostgreSQL enum column once did. ``fixtures/public_api/household_read_columns.json`` +records each column read as the Public API's migrations create it. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from policyengine_simulation_persistence.household_reads import ( + household_read_metadata, +) +from public_api_support import superuser_engine +from sqlalchemy import text + +RECORDED_COLUMNS = ( + Path(__file__).parent / "fixtures" / "public_api" / "household_read_columns.json" +) + + +def _recorded() -> dict[str, dict[str, dict[str, object]]]: + return json.loads(RECORDED_COLUMNS.read_text(encoding="utf-8"))["tables"] + + +def _copied(superuser_url: str, tables: list[str]) -> dict[str, dict[str, dict]]: + engine = superuser_engine(superuser_url) + try: + with engine.connect() as connection: + rows = connection.execute( + text( + "SELECT table_name, column_name, udt_name, is_nullable, " + "character_maximum_length FROM information_schema.columns " + "WHERE table_schema = 'public' AND table_name = ANY(:tables)" + ), + {"tables": tables}, + ).all() + finally: + engine.dispose() + copied: dict[str, dict[str, dict]] = {} + for table, column, column_type, nullable, length in rows: + description: dict[str, object] = { + "type": column_type, + "nullable": nullable == "YES", + } + if length is not None: + description["length"] = length + copied.setdefault(table, {})[column] = description + return copied + + +@pytest.mark.postgres +def test_each_column_the_household_reads_select_matches_the_public_api( + disposable_database, +) -> None: + recorded = _recorded() + + copied = _copied(disposable_database.superuser_url, sorted(recorded)) + + mismatches = [ + f"{table}.{column}: copy {copied.get(table, {}).get(column)!r}, " + f"Public API {description!r}" + for table, columns in recorded.items() + for column, description in columns.items() + if copied.get(table, {}).get(column) != description + ] + assert mismatches == [] + + +def test_every_column_the_household_reads_select_is_recorded() -> None: + """A column added to a read must be recorded, so its copy is checked too.""" + recorded = _recorded() + + unrecorded = [ + f"{table.name}.{column.name}" + for table in household_read_metadata.tables.values() + for column in table.columns + if column.name not in recorded.get(table.name, {}) + ] + + assert unrecorded == [] 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_error_details.py b/libs/policyengine-simulation-persistence/tests/test_run_error_details.py new file mode 100644 index 000000000..82c1e8127 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_run_error_details.py @@ -0,0 +1,179 @@ +"""A failure may carry a detail document, stored with the run it failed.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_persistence import ( + NewReportRun, + RunError, + RunErrorCode, + RunStatus, + RunTrigger, +) +from public_api_support import superuser_engine +from report_support import economy_pair, economy_report, household_baseline +from report_support import report_store as definition_report_store +from run_support import economy_run, household_run +from simulation_support import household_simulation, simulation_store +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from transition_support import clocked_stores + +pytestmark = pytest.mark.postgres + +INPUT_ERRORS = { + "code": "invalid_inputs", + "message": "Unrecognized inputs: employment_incme", + "input_errors": [ + { + "type": "household_variable", + "name": "employment_incme", + "path": "people/you/employment_incme", + "message": "employment_incme is not a variable", + } + ], +} +REJECTED = RunError( + RunErrorCode.WORKER_ERROR, + "The household calculation failed: invalid_inputs.", + detail=INPUT_ERRORS, +) + + +def _running_household_pair(stores, database, rows, call_id="fc-household"): + baseline = household_baseline(database, rows) + reform = simulation_store(database).create( + household_simulation(rows, policy_id=rows.us_policy_id) + ) + run_ids = [ + stores.runs.start_simulation_run(household_run(simulation, rows)).run.id + for simulation in (baseline, reform) + ] + assert stores.transitions.claim_simulation_dispatch(run_ids) + assert stores.transitions.record_simulation_dispatched( + run_ids, modal_call_id=call_id, first_check_at=stores.clock() + ) + return run_ids + + +def test_a_failed_call_records_its_detail_on_every_run_it_computes( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run_ids = _running_household_pair(stores, migrated_database, public_api_rows) + + assert stores.transitions.fail_simulation_call("fc-household", REJECTED) + + for run_id in run_ids: + failed = stores.runs.get_simulation_run(run_id) + assert failed.status is RunStatus.FAILED + assert failed.error_message == REJECTED.message + assert failed.error_detail == INPUT_ERRORS + + +def test_a_failure_without_detail_records_none( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run_ids = _running_household_pair(stores, migrated_database, public_api_rows) + + assert stores.transitions.fail_simulation_call( + "fc-household", RunError(RunErrorCode.WORKER_TIMEOUT, "The call timed out.") + ) + + assert stores.runs.get_simulation_run(run_ids[0]).error_detail is None + + +def test_a_refused_dispatch_records_its_detail( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run = stores.runs.start_simulation_run( + household_run( + household_baseline(migrated_database, public_api_rows), public_api_rows + ) + ).run + stores.transitions.claim_simulation_dispatch([run.id]) + refused = RunError( + RunErrorCode.DISPATCH_FAILED, + "Starting the Modal call raised NotFoundError.", + detail={"modal_function": "calculate_household_us"}, + ) + + assert stores.transitions.record_simulation_dispatch_failure([run.id], refused) + + assert stores.runs.get_simulation_run(run.id).error_detail == { + "modal_function": "calculate_household_us" + } + + +def test_a_failed_comparison_records_its_detail( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + baseline, reform = economy_pair(migrated_database, public_api_rows) + report = definition_report_store(migrated_database).create( + economy_report(baseline, reform) + ) + runs = [ + stores.runs.start_simulation_run(economy_run(simulation, public_api_rows)).run + for simulation in (baseline, reform) + ] + report_run = stores.runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=runs[0].id, + reform_simulation_run_id=runs[1].id, + trigger=RunTrigger.INITIAL, + ) + ) + for run in runs: + call_id = f"fc-{run.id.hex[:8]}" + stores.transitions.claim_simulation_dispatch([run.id]) + stores.transitions.record_simulation_dispatched( + [run.id], modal_call_id=call_id, first_check_at=stores.clock() + ) + stores.transitions.complete_simulation_call(call_id, lambda *_: None) + stores.transitions.claim_comparison_dispatch(report_run.id) + stores.transitions.record_comparison_dispatched( + report_run.id, modal_call_id="fc-comparison", first_check_at=stores.clock() + ) + + assert stores.transitions.fail_comparison( + report_run.id, + RunError( + RunErrorCode.WORKER_ERROR, + "The Modal call raised ReportExecutionError.", + detail={"code": "missing_parameter"}, + ), + ) + + failed = stores.runs.get_report_run(report_run.id) + assert failed.comparison_error_detail == {"code": "missing_parameter"} + + +def test_the_database_keeps_details_for_failed_runs_only( + migrated_database, public_api_rows +) -> None: + stores = clocked_stores(migrated_database) + run_ids = _running_household_pair(stores, migrated_database, public_api_rows) + engine = superuser_engine(migrated_database.superuser_url) + try: + with ( + pytest.raises(IntegrityError, match="ck_simulation_runs_error_detail"), + engine.begin() as connection, + ): + connection.execute( + text( + "UPDATE simulation.simulation_runs " + "SET error_detail = '{}'::jsonb WHERE id = :run_id" + ), + {"run_id": run_ids[0]}, + ) + finally: + engine.dispose() + + +def test_an_error_detail_must_be_a_json_object() -> None: + with pytest.raises(TypeError, match="detail must be a JSON object"): + RunError(RunErrorCode.WORKER_ERROR, "x", detail=["invalid_inputs"]) # type: ignore[arg-type] diff --git a/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..6ad236bf5 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_runs.py @@ -0,0 +1,495 @@ +"""simulation_runs and report_runs: executions of simulations and reports.""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +import pytest +from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, + NewReportRun, + ReportKind, + ReportRunRecord, + RunStatus, + RunTrigger, + SimulationRunRecord, + create_simulation_engine, +) +from public_api_support import delete_public_api_row +from report_support import ( + economy_pair, + economy_report, + household_baseline, + report_store, +) +from run_support import ( + economy_run, + household_run, + run_store, + set_simulation_run_status, +) +from simulation_support import ( + economy_simulation, + household_simulation, + simulation_store, +) +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError, ProgrammingError + +pytestmark = pytest.mark.postgres + + +def test_an_economy_run_records_the_region_and_dataset_it_resolved( + migrated_database, public_api_rows +) -> None: + _, reform = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + + started = store.start_simulation_run(economy_run(reform, public_api_rows)) + + run = started.run + assert started.created is True + assert isinstance(run, SimulationRunRecord) + assert run.simulation_id == reform.id + assert run.simulation_kind == "economy" + assert run.tax_benefit_model_version_id == public_api_rows.catalog_version_id + assert run.year == 2026 + assert run.attempt == 1 + assert run.region_code == "state/ca" + assert run.dataset_id == public_api_rows.dataset_id + assert run.status is RunStatus.PENDING + assert run.trigger is RunTrigger.INITIAL + assert run.modal_call_id is None + assert run.completed_at is None + assert store.get_simulation_run(run.id) == run + + +def test_a_run_that_exists_is_reused(migrated_database, public_api_rows) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + again = store.start_simulation_run( + economy_run(baseline, public_api_rows, trigger=RunTrigger.MANUAL) + ) + + assert again.created is False + assert again.run == first.run + + +def test_a_forced_rerun_adds_the_next_attempt( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + forced = store.start_simulation_run( + economy_run(baseline, public_api_rows, trigger=RunTrigger.MANUAL), force=True + ) + + assert forced.created is True + assert forced.run.attempt == first.run.attempt + 1 + assert forced.run.trigger is RunTrigger.MANUAL + latest = store.latest_simulation_run( + baseline.id, public_api_rows.catalog_version_id, 2026 + ) + assert latest == forced.run + + +def test_a_failed_run_is_not_reused(migrated_database, public_api_rows) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + set_simulation_run_status(migrated_database, first.run.id, "failed") + + again = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + assert again.created is True + assert again.run.attempt == 2 + + +@pytest.mark.parametrize( + ("completed_days_ago", "reused"), + [(27, True), (29, False)], + ids=["within-28-days", "older-than-28-days"], +) +def test_a_succeeded_run_is_reused_only_within_28_days( + migrated_database, public_api_rows, completed_days_ago, reused +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + set_simulation_run_status( + migrated_database, + first.run.id, + "succeeded", + completed_days_ago=completed_days_ago, + ) + + again = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + assert again.created is not reused + assert again.run.attempt == (1 if reused else 2) + assert timedelta(days=28) == SUCCEEDED_RUN_REUSE_WINDOW + + +def test_each_catalog_version_and_year_has_its_own_runs( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + store = run_store(migrated_database) + first = store.start_simulation_run(economy_run(baseline, public_api_rows)) + + newer = store.start_simulation_run( + economy_run( + baseline, + public_api_rows, + tax_benefit_model_version_id=public_api_rows.newer_catalog_version_id, + dataset_id=public_api_rows.newer_dataset_id, + ) + ) + next_year = store.start_simulation_run( + economy_run(baseline, public_api_rows, year=2027) + ) + + assert len({first.run.id, newer.run.id, next_year.run.id}) == 3 + assert newer.created is True + assert next_year.created is True + assert newer.run.attempt == next_year.run.attempt == 1 + + +@pytest.mark.parametrize( + ("overrides", "constraint"), + [ + ({"dataset_attribute": "newer_dataset_id"}, "fk_simulation_runs_dataset"), + ({"region_code": None}, "population_matches_kind"), + ({"dataset_attribute": None}, "population_matches_kind"), + ], + ids=[ + "dataset-of-another-version", + "economy-without-region", + "economy-without-dataset", + ], +) +def test_an_economy_run_uses_only_its_own_versions_dataset( + migrated_database, public_api_rows, overrides, constraint +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + values = dict(overrides) + if "dataset_attribute" in values: + attribute = values.pop("dataset_attribute") + values["dataset_id"] = ( + None if attribute is None else getattr(public_api_rows, attribute) + ) + + with pytest.raises(IntegrityError, match=constraint): + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows, **values) + ) + + +def test_an_economy_run_needs_its_region_in_its_catalog_version( + migrated_database, public_api_rows +) -> None: + new_york = simulation_store(migrated_database).create( + economy_simulation(public_api_rows, region_code="state/ny") + ) + + with pytest.raises(IntegrityError, match="fk_simulation_runs_region"): + run_store(migrated_database).start_simulation_run( + economy_run(new_york, public_api_rows, region_code="state/ny") + ) + + +def test_an_economy_run_uses_its_simulations_region( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(ValueError, match="region_code"): + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows, region_code="state/ny") + ) + + +def test_a_household_run_has_no_region_or_dataset( + migrated_database, public_api_rows +) -> None: + simulation = household_baseline(migrated_database, public_api_rows) + store = run_store(migrated_database) + + started = store.start_simulation_run(household_run(simulation, public_api_rows)) + assert started.run.simulation_kind == "household" + assert started.run.region_code is None + + with pytest.raises(IntegrityError, match="population_matches_kind"): + store.start_simulation_run( + household_run( + simulation, + public_api_rows, + year=2027, + region_code="state/ca", + dataset_id=public_api_rows.dataset_id, + ) + ) + + +def test_a_run_of_an_unknown_simulation_is_refused( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + + with pytest.raises(LookupError, match="simulation"): + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows, simulation_id=uuid4()) + ) + + +def test_the_public_api_cannot_delete_a_dataset_a_run_used( + migrated_database, public_api_rows +) -> None: + baseline, _ = economy_pair(migrated_database, public_api_rows) + run_store(migrated_database).start_simulation_run( + economy_run(baseline, public_api_rows) + ) + + with pytest.raises(IntegrityError, match="violates foreign key constraint"): + delete_public_api_row( + migrated_database.superuser_url, "datasets", public_api_rows.dataset_id + ) + + +def _economy_report_with_runs(database, rows): + baseline, reform = economy_pair(database, rows) + report = report_store(database).create(economy_report(baseline, reform)) + store = run_store(database) + baseline_run = store.start_simulation_run(economy_run(baseline, rows)).run + reform_run = store.start_simulation_run(economy_run(reform, rows)).run + return report, baseline_run, reform_run + + +def test_an_economy_report_run_joins_two_runs_and_waits_for_its_comparison( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + + report_run = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + assert isinstance(report_run, ReportRunRecord) + assert report_run.report_kind == "economy" + assert report_run.tax_benefit_model_version_id == ( + public_api_rows.catalog_version_id + ) + assert report_run.year == 2026 + assert report_run.comparison_status is RunStatus.PENDING + assert report_run.comparison_modal_call_id is None + assert store.get_report_run(report_run.id) == report_run + assert store.latest_report_run(report.id) == report_run + + +def test_both_runs_of_a_report_run_share_its_version_and_year( + migrated_database, public_api_rows +) -> None: + report, baseline_run, _ = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + reform = report_store(migrated_database).get(report.id) + assert reform is not None + later_reform_run = store.start_simulation_run( + economy_run( + simulation_store(migrated_database).get(reform.reform_simulation_id), + public_api_rows, + year=2027, + ) + ).run + + with pytest.raises(IntegrityError, match="fk_report_runs_reform_simulation_run"): + store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=later_reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +def test_a_report_run_uses_only_runs_of_the_reports_simulations( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + + with pytest.raises(ValueError, match="report's simulations"): + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=reform_run.id, + reform_simulation_run_id=baseline_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +def test_an_economy_report_run_needs_its_reform_run( + migrated_database, public_api_rows +) -> None: + report, baseline_run, _ = _economy_report_with_runs( + migrated_database, public_api_rows + ) + + with pytest.raises(ValueError, match="report's simulations"): + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +def test_a_household_report_run_has_no_comparison_step( + migrated_database, public_api_rows +) -> None: + store = simulation_store(migrated_database) + baseline = household_baseline(migrated_database, public_api_rows) + reform = store.create( + household_simulation(public_api_rows, policy_id=public_api_rows.us_policy_id) + ) + report = report_store(migrated_database).create( + economy_report(baseline, reform, kind=ReportKind.HOUSEHOLD) + ) + runs = run_store(migrated_database) + baseline_run = runs.start_simulation_run( + household_run(baseline, public_api_rows) + ).run + reform_run = runs.start_simulation_run(household_run(reform, public_api_rows)).run + + report_run = runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.SYSTEM, + ) + ) + + assert report_run.report_kind == "household" + assert report_run.comparison_status is None + assert report_run.trigger is RunTrigger.SYSTEM + + +def test_a_single_simulation_household_report_run_has_no_reform_run( + migrated_database, public_api_rows +) -> None: + baseline = household_baseline(migrated_database, public_api_rows) + report = report_store(migrated_database).create( + economy_report( + baseline, baseline, kind=ReportKind.HOUSEHOLD, reform_simulation_id=None + ) + ) + runs = run_store(migrated_database) + baseline_run = runs.start_simulation_run( + household_run(baseline, public_api_rows) + ).run + + report_run = runs.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + assert report_run.reform_simulation_run_id is None + + +def test_the_newest_report_run_is_the_reports_current_run( + migrated_database, public_api_rows +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + store = run_store(migrated_database) + new_report_run = NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + store.create_report_run(new_report_run) + + rerun = store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.MANUAL, + ) + ) + + assert store.latest_report_run(report.id) == rerun + assert store.latest_report_run(uuid4()) is None + + +def test_a_report_run_of_an_unknown_report_is_refused( + migrated_database, public_api_rows +) -> None: + _, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + + with pytest.raises(LookupError, match="report"): + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=uuid4(), + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + + +@pytest.mark.parametrize("table_name", ["simulation_runs", "report_runs"]) +def test_the_runtime_role_updates_runs_but_cannot_delete_them( + migrated_database, public_api_rows, table_name +) -> None: + report, baseline_run, reform_run = _economy_report_with_runs( + migrated_database, public_api_rows + ) + run_store(migrated_database).create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=reform_run.id, + trigger=RunTrigger.INITIAL, + ) + ) + engine = create_simulation_engine(migrated_database.runtime_url) + try: + with engine.begin() as connection: + updated = connection.execute( + text(f"UPDATE simulation.{table_name} SET updated_at = now()") # noqa: S608 # fixed table names + ) + assert updated.rowcount >= 1 + with ( + pytest.raises(ProgrammingError, match="permission denied"), + engine.begin() as connection, + ): + connection.execute(text(f"DELETE FROM simulation.{table_name}")) # noqa: S608 # fixed table names + finally: + engine.dispose() diff --git a/libs/policyengine-simulation-persistence/tests/test_schema.py b/libs/policyengine-simulation-persistence/tests/test_schema.py 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/migrations/versions/0005_household_calculations.py b/migrations/versions/0005_household_calculations.py new file mode 100644 index 000000000..5ddd03ea1 --- /dev/null +++ b/migrations/versions/0005_household_calculations.py @@ -0,0 +1,125 @@ +"""Add what household calculations need. + +A simulation gains ``options``: whatever else changes what it calculates, +such as a household's axes, so a run is reused only for the same options. +Existing simulations get empty options. A computed household gains its +warnings (an empty list by default) and three documents, each null when it +does not apply: the SPM selection the calculation applied, its SPM receipt, +and the PolicyEngine.py bundle it ran with. A failed simulation run, or +comparison step, may carry a detail document beyond its error code and +message, such as the inputs a household calculation rejected. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-10-27 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +HOUSEHOLD_OUTPUT_DOCUMENTS = ("spm_config", "spm_provenance", "bundle") +# Each run table's status column and the prefix of its error columns. +ERROR_DETAIL_COLUMNS = ( + ("simulation_runs", "status", ""), + ("report_runs", "comparison_status", "comparison_"), +) + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add the simulation options, the household notes and the error details.""" + op.add_column( + "simulations", + sa.Column( + "options", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + schema="simulation", + ) + op.create_check_constraint( + op.f("ck_simulations_options"), + "simulations", + "jsonb_typeof(options) = 'object'", + schema="simulation", + ) + op.add_column( + "household_outputs", + sa.Column( + "warnings", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'[]'::jsonb"), + nullable=False, + ), + schema="simulation", + ) + op.create_check_constraint( + op.f("ck_household_outputs_warnings"), + "household_outputs", + "jsonb_typeof(warnings) = 'array'", + schema="simulation", + ) + for document in HOUSEHOLD_OUTPUT_DOCUMENTS: + op.add_column( + "household_outputs", + sa.Column(document, postgresql.JSONB(astext_type=sa.Text()), nullable=True), + schema="simulation", + ) + op.create_check_constraint( + op.f(f"ck_household_outputs_{document}"), + "household_outputs", + f"{document} IS NULL OR jsonb_typeof({document}) = 'object'", + schema="simulation", + ) + for table, status, prefix in ERROR_DETAIL_COLUMNS: + op.add_column( + table, + sa.Column( + f"{prefix}error_detail", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + schema="simulation", + ) + op.create_check_constraint( + op.f(f"ck_{table}_{prefix}error_detail"), + table, + f"{prefix}error_detail IS NULL OR ({status} = 'failed' " + f"AND jsonb_typeof({prefix}error_detail) = 'object')", + schema="simulation", + ) + + +def downgrade() -> None: + """Drop what ``upgrade`` added.""" + for table, _status, prefix in ERROR_DETAIL_COLUMNS: + op.drop_constraint( + op.f(f"ck_{table}_{prefix}error_detail"), + table, + schema="simulation", + type_="check", + ) + op.drop_column(table, f"{prefix}error_detail", schema="simulation") + for document in (*HOUSEHOLD_OUTPUT_DOCUMENTS, "warnings"): + op.drop_constraint( + op.f(f"ck_household_outputs_{document}"), + "household_outputs", + schema="simulation", + type_="check", + ) + op.drop_column("household_outputs", document, schema="simulation") + op.drop_constraint( + op.f("ck_simulations_options"), + "simulations", + schema="simulation", + type_="check", + ) + op.drop_column("simulations", "options", schema="simulation") diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 9783ef1cc..95fab18c1 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -22,6 +22,158 @@ 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; staging and production deployments both promote the revision +they tested, so the main URL reaches it once promotion succeeds. + +Household calculations (`POST /v2/household-calculations`, see the README) +start the v2 household functions' calls in the same Modal environment, +`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`, with the same token, and read the +v2 version manifest there. They read the household, its policies and the +catalog version from the Public API's tables through `SIMULATION_DATABASE_URL`, +so before they are used the owner of those tables must grant the runtime role +`simulation_runtime` `SELECT` on `tax_benefit_models`, +`tax_benefit_model_versions`, `households`, `policies`, `parameters` and +`parameter_values`; without it, submissions answer 503. Their outcomes are +recorded like any other call's: on the call's report, or by the safety check. +The household functions take the runs a call computes as their second +argument. A v2 application deployed before they did (for an older +PolicyEngine.py version) refuses that argument, so its calls fail as worker +errors, recorded by the safety check; redeploying that version's v2 +application fixes it. + +### Staging household test + +After each staging deployment promotes its revision, with the v2 application +deployed, the `staging_household_test` job runs +`authenticated_tests/test_staging_household_calculation.py` against the +staging stable URL: it submits a US household calculation, polls until it +succeeds or fails, and reads the result back, all within ten minutes; an +answer asking to retry (503) is retried after its wait within that limit. It runs +after promotion because the household function reports the call's end to the +stable URL. A failure fails the deployment run, so production does not +deploy. + +The calculation reads rows the Public API owns in the same database, so +the test first finds the staging Public API's US catalog version for the +deployed PolicyEngine.py version (or its current one, when the Public API +has not published that version yet) and stores a one-person household there +(`POST /v2/households`, which returns the same household every time), then +submits that household's ID and the catalog version's PolicyEngine.py +version. The +`beta` environment variable `PUBLIC_API_URL` names the staging Public API's +URL. The test authenticates as the deployed-test client +(`SIMULATION_ENTRYPOINT_TEST_AUTH_*`), which needs `simulation:submit` and +`simulation:read` when permissions are enforced. The other runs of +`authenticated_tests` skip it: only this job sets +`SIMULATION_STAGING_HOUSEHOLD_TEST=1`. + +## Safety check + +A call whose report never arrives is recorded by the safety check, the Cloud +Run job `-safety-check`. Each execution runs one recording +pass (`python -m policyengine_simulation_entry.safety_check`) and exits: it +fails runs left `dispatching` for ten minutes and records what Modal reports +for calls dispatched, or last checked, at least five minutes ago. Cloud +Scheduler starts it every five minutes (`*/5 * * * *`, UTC): a lost report is +recorded within about ten minutes, and each execution is a single short +container start, so the job costs little when every call reports. + +`.github/scripts/deploy-cloud-run-simulation-safety-check.sh` points the job +at one entrypoint revision. It copies that revision's image digest, service +account, the secret versions it pins for `SIMULATION_DATABASE_URL`, +`MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, and its plain telemetry settings, +then creates or updates the schedule. Only two places call it, so the job +only ever runs a revision that serves traffic and a rejected candidate never +records anything: + +- the promotion job, after the promoted revision passes every check; if this + step fails, the previous revision is restored and the job is pointed back + at it, 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. + +Staging deployments promote their tested revision too, so staging has the +safety check as production does. + +One-time operator steps, in each environment: enable the Cloud Scheduler +API; create the scheduler's service account, give it `roles/run.invoker` +on the job (or the project), and set its email in the GitHub environment +variable `SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT`; give the deploy +service account permission to deploy Cloud Run jobs and manage Cloud +Scheduler jobs, and `iam.serviceAccounts.actAs` on the scheduler's service +account. The job uses the entrypoint's runtime service account, which can +already read its secrets. + ## Candidate qualification For each eligible revision, the workflow first deploys `beta` @@ -33,17 +185,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 +219,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 +254,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 +289,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..557c082e5 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -21,8 +21,58 @@ 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. The staging household test there runs only in its own +job after a staging promotion (`DEPLOYMENT.md`, "Staging household test"). + +## Household calculations + +`POST /v2/household-calculations` records a household calculation, one +stored household under one stored policy or current law, and starts its +Modal call; `GET /v2/household-calculations/{calculation_id}` returns its +status and, once it succeeded, the calculated household with its warnings, +the SPM selection it applied (`spm_config`), its SPM receipt +(`spm_provenance`) and its bundle, or, once it failed, its error. The Public +API sends the IDs of its household and policy and the PolicyEngine.py +version; this service reads, from the Public API's tables in the same +database, the country's catalog version for that PolicyEngine.py version, +the household (its document, saved SPM choice and `default_year`, the year +calculated) and the policy's parameter values, before writing anything. +Every household and policy value is checked against the household +functions' contract too, all before anything is written. A household, policy +or catalog version the Public API does not have for the country, and a +household without a default year, are refused with 400 and the reason; a +policy changing a parameter the catalog version lacks, and a stored policy +value or household the contract refuses (such as an object as a value, or a +group no person belongs to), are the caller's data and are refused with 422 +and the reason, naming at most ten fields or parameters. The first problem +found is the one answered, in the order the rows are read: the catalog +version, the household, then each policy. The runtime role needs `SELECT` on those tables +(`libs/policyengine-simulation-persistence`, "Household calculations"); +without it, the routes answer 503 saying sim cannot read them. + +Each calculation is a simulation run of a household simulation (household, +policy and options such as axes), so the same calculation submitted again +reuses its run while it is unfinished or for 28 days after it succeeded, +unless `force` is set; after that, it runs again under a new calculation ID. +The household function comes from the v2 version manifest for the +calculation's PolicyEngine.py version, in the v2 application's Modal +environment (`SIMULATION_CALL_REPORT_MODAL_ENVIRONMENT`). The function +reports when its call ends, and the outcome is recorded then +(`DEPLOYMENT.md`, "Recording run outcomes"). Without the simulation database +or that environment, the routes answer 503. + +A household report compares the household under a baseline and an optional +reform. `POST /v2/household-reports` creates it and starts its first run; +`POST /v2/household-reports/{report_id}/runs` starts another run of it on a +PolicyEngine.py version, reading the report's own household and policies +again; `GET /v2/household-reports/{report_id}` +returns it with its newest run and that run's two calculations. Each report +run joins a run of each simulation, reused like any calculation's, and one +Modal call computes the runs that still need computing, so a new report's +two households are calculated together. A report run has failed when either +calculation failed and succeeded when both did. ## Caller authentication and permissions @@ -37,6 +87,11 @@ Auth0 permission: | `POST /simulate/economy/budget-window` | `simulation:submit` | | `GET /jobs/{job_id}` | `simulation:read` | | `GET /budget-window-jobs/{batch_job_id}` | `simulation:read` | +| `POST /v2/household-calculations` | `simulation:submit` | +| `GET /v2/household-calculations/{calculation_id}` | `simulation:read` | +| `POST /v2/household-reports` | `simulation:submit` | +| `POST /v2/household-reports/{report_id}/runs` | `simulation:submit` | +| `GET /v2/household-reports/{report_id}` | `simulation:read` | | `POST /internal/stage12/reports` | `stage12:operate` | | `GET /internal/stage12/reports/{evaluation_id}` | `stage12:operate` | diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py new file mode 100644 index 000000000..365899354 --- /dev/null +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_staging_household_calculation.py @@ -0,0 +1,244 @@ +"""Staging: calculate a household through the stable URL and read the result back. + +The deploy's staging household job runs this after promotion, with +``SIMULATION_STAGING_HOUSEHOLD_TEST=1``; every other run of the deployed tests +skips it. It runs against the stable URL because the household function +reports the call's end there, so only the promoted revision records it. + +The calculation needs rows the Public API owns in the same database, which +sim reads itself: a catalog version and a stored household. The test finds +the staging Public API's US catalog version for the deployed PolicyEngine.py +version (or, when the Public API has not published that version yet, its +current one) and stores a one-person household there, which returns the +same household each time. It then submits that household's ID and the +catalog version's PolicyEngine.py version, forced so each deploy starts a +new Modal call, polls until it succeeds or fails, and reads the result +back, all within one ten-minute limit. An answer asking to retry (503 +with ``Retry-After``) is retried after its wait, within that limit; a +submission retried after it was partly written may leave a pending run +behind, which nothing reuses, since each retry is forced too. +""" + +from __future__ import annotations + +import os +import time +from typing import TYPE_CHECKING, Any + +import httpx +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + +STAGING_HOUSEHOLD_TEST_FLAG = "SIMULATION_STAGING_HOUSEHOLD_TEST" +pytestmark = pytest.mark.skipif( + os.environ.get(STAGING_HOUSEHOLD_TEST_FLAG) != "1", + reason="Runs only in the staging household job, after promotion.", +) + +YEAR = 2026 +POLL_INTERVAL_SECONDS = 5 +# A household container that starts cold installs nothing but loads the US +# model, about a minute; the rest allows for a queued container and for the +# call's report to be recorded. +CALCULATION_DEADLINE_SECONDS = 600 +# One adult in California, as the Public API stores it, asking for the +# household's net income. +STORED_HOUSEHOLD = { + "people": [ + { + "id": "you", + "values": { + "age": {str(YEAR): 40}, + "employment_income": {str(YEAR): 30_000}, + }, + "memberships": { + "household": "your household", + "family": "your family", + "tax_unit": "your tax unit", + "spm_unit": "your spm unit", + "marital_unit": "your marital unit", + }, + } + ], + "household": [ + { + "id": "your household", + "values": { + "state_name": {str(YEAR): "CA"}, + "household_net_income": {str(YEAR): None}, + }, + } + ], + "family": [{"id": "your family", "values": {}}], + "tax_unit": [{"id": "your tax unit", "values": {}}], + "spm_unit": [{"id": "your spm unit", "values": {}}], + "marital_unit": [{"id": "your marital unit", "values": {}}], +} + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + msg = f"{name} is required for the staging household test." + raise RuntimeError(msg) + return value + + +@pytest.fixture(scope="session") +def base_url() -> str: + """Return the staging stable URL, without a trailing slash.""" + return _required("SIMULATION_STAGING_TEST_BASE_URL").rstrip("/") + + +@pytest.fixture(scope="session") +def public_api_url() -> str: + """Return the staging Public API's URL, without a trailing slash.""" + return _required("SIMULATION_STAGING_TEST_PUBLIC_API_URL").rstrip("/") + + +@pytest.fixture(scope="session") +def authorization() -> dict[str, str]: + """Return a bearer header with a client-credentials token for the service.""" + issuer = _required("SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER").rstrip("/") + response = httpx.post( + f"{issuer}/oauth/token", + json={ + "client_id": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID"), + "client_secret": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET"), + "audience": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE"), + "grant_type": "client_credentials", + }, + timeout=30, + ) + response.raise_for_status() + token = response.json().get("access_token") + if not isinstance(token, str) or not token: + msg = "Auth0 response omitted access_token." + raise RuntimeError(msg) + return {"Authorization": f"Bearer {token}"} + + +def catalog_policyengine_version(public_api_url: str, policyengine_version: str) -> str: + """Return the deployed version if the Public API publishes it, else its current.""" + url = f"{public_api_url}/v2/tax-benefit-models/by-country/us" + response = httpx.get( + url, params={"policyengine_version": policyengine_version}, timeout=30 + ) + if response.status_code == httpx.codes.NOT_FOUND: + response = httpx.get(url, timeout=30) + response.raise_for_status() + return response.json()["result"]["policyengine_version"] + + +def stored_household_id(public_api_url: str) -> str: + """Store the household in the Public API, or find it, and return its ID.""" + response = httpx.post( + f"{public_api_url}/v2/households", + params={"country_id": "us"}, + json={ + "country_id": "us", + "default_year": YEAR, + "household_data": STORED_HOUSEHOLD, + }, + timeout=30, + ) + assert response.status_code in {httpx.codes.OK, httpx.codes.CREATED}, response.text + return response.json()["result"]["item"]["id"] + + +def submission_body(household_id: str, policyengine_version: str) -> dict[str, Any]: + """Return a forced calculation of the stored household, by ID only.""" + return { + "country": "us", + "policyengine_version": policyengine_version, + "household_id": household_id, + "force": True, + } + + +def answer_within( + send: Callable[[], httpx.Response], deadline: float +) -> httpx.Response: + """Send until the service answers other than 503, or the deadline passes. + + A 503 asks to retry after its ``Retry-After`` wait; the last answer is + returned when the deadline would pass first. + """ + while True: + response = send() + if response.status_code != httpx.codes.SERVICE_UNAVAILABLE: + return response + wait = float(response.headers.get("retry-after", POLL_INTERVAL_SECONDS)) + if time.monotonic() + wait >= deadline: + return response + time.sleep(wait) + + +def poll_until_finished( + base_url: str, + calculation_id: str, + authorization: dict[str, str], + *, + deadline: float, +) -> dict[str, Any]: + """Poll the calculation until it succeeds or fails, before ``deadline``.""" + while True: + response = answer_within( + lambda: httpx.get( + f"{base_url}/v2/household-calculations/{calculation_id}", + headers=authorization, + timeout=30, + ), + deadline, + ) + assert response.status_code == httpx.codes.OK, response.text + calculation = response.json() + if calculation["status"] in {"succeeded", "failed"}: + return calculation + assert time.monotonic() < deadline, ( + f"calculation {calculation_id} still {calculation['status']} at the " + f"{CALCULATION_DEADLINE_SECONDS}-second limit" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + +def test_a_household_calculation_succeeds_and_reads_back( + base_url: str, public_api_url: str, authorization: dict[str, str] +) -> None: + deadline = time.monotonic() + CALCULATION_DEADLINE_SECONDS + policyengine_version = catalog_policyengine_version( + public_api_url, _required("SIMULATION_STAGING_TEST_POLICYENGINE_VERSION") + ) + household_id = stored_household_id(public_api_url) + submitted = answer_within( + lambda: httpx.post( + f"{base_url}/v2/household-calculations", + json=submission_body(household_id, policyengine_version), + headers=authorization, + timeout=60, + ), + deadline, + ) + assert submitted.status_code == httpx.codes.ACCEPTED, submitted.text + calculation_id = submitted.json()["calculation_id"] + + finished = poll_until_finished( + base_url, calculation_id, authorization, deadline=deadline + ) + + assert finished["status"] == "succeeded", finished["error"] + assert finished["year"] == YEAR + result = finished["result"] + household = result["household"]["households"]["your household"] + net_income = household["household_net_income"][str(YEAR)] + assert isinstance(net_income, int | float) + assert net_income > 0 + assert result["bundle"]["policyengine_version"] == policyengine_version + read_back = httpx.get( + f"{base_url}/v2/household-calculations/{calculation_id}", + headers=authorization, + timeout=30, + ) + assert read_back.json() == finished diff --git a/projects/policyengine-simulation-entry/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..83d35841d 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,6 +52,11 @@ CallerAuthorizer, ) from policyengine_simulation_entry.backend import OldGatewayBackend, SimulationBackend +from policyengine_simulation_entry.call_report_route import ( + CallReportRecorder, + ModalCallerVerifier, + register_call_report_route, +) from policyengine_simulation_entry.config import Settings from policyengine_simulation_entry.entry_runtime import ( BACKEND_RESPONSE_HEADER, @@ -59,15 +65,24 @@ ComparisonBackend, EntryRuntime, ResponseIdentifier, + SimulationDatabaseConnection, model_json, request_identifiers, route_template, set_observability_header, ) +from policyengine_simulation_entry.household_routes import ( + HouseholdCalculations, + register_household_calculation_routes, +) +from policyengine_simulation_entry.modal_identity import ModalIdentityVerifier from policyengine_simulation_entry.schemas import ( + SimulationDatabaseReadiness, + SimulationEntrypointReadinessResponse, TemporaryStage12ReportResponse, TemporaryStage12SubmissionResponse, ) +from policyengine_simulation_entry.simulation_database import SimulationDatabase from policyengine_simulation_entry.stage12_backend import ( Stage12ComparisonBackend, TemporaryStage12DispatchFailed, @@ -84,11 +99,31 @@ "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 v2 routes' seams, for tests. + + ``create_app`` builds each one it is not given: the database from + ``SIMULATION_DATABASE_URL``, the call recorder from that database, the + Modal identity verifier from the expected Modal caller, and the household + calculations from the database and the v2 application's Modal + environment. + """ + + database: SimulationDatabaseConnection | None = None + call_recorder: CallReportRecorder | None = None + modal_identity_verifier: ModalCallerVerifier | None = None + household_calculations: HouseholdCalculations | None = None def _temporary_submission_payload( @@ -456,6 +491,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 +551,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 +587,32 @@ 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() + 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) + household_calculations = services.household_calculations + if ( + household_calculations is None + and isinstance(runtime_database, SimulationDatabase) + and runtime_settings.v2_modal_environment + ): + household_calculations = runtime_database.household_calculations( + runtime_settings.v2_modal_environment + ) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -527,6 +622,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 +662,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 +675,15 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: reader=simulation_reader, ) _register_service_routes(app, entry) + register_household_calculation_routes( + app, + household_calculations, + submitter=simulation_submitter, + reader=simulation_reader, + ) + register_call_report_route( + app, verifier=modal_identity_verifier, recorder=call_recorder + ) 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..48aac04e4 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,28 @@ 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 v2_modal_environment(self) -> str: + """The Modal environment of the v2 application, or empty when unset. + + The v2 application's report_call_ended function reports calls from + it, so it is the call-report environment; the household routes start + calls and read the v2 version manifest there. + """ + return self.call_report_modal_environment + @property def stage12_resources_configured(self) -> bool: """Whether the Stage 12 manifest environment, database and bucket are set.""" @@ -170,6 +213,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 +312,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/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/household_api_models.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py new file mode 100644 index 000000000..bce3b4828 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_api_models.py @@ -0,0 +1,368 @@ +"""Request and response models of the household calculation routes. + +The Public API sends the IDs of its stored household and policies and the +PolicyEngine.py version to calculate with; the Simulation Entrypoint reads +the household, the policies' parameter values and the catalog version from +the Public API's tables itself (``HouseholdInputReader``), and calculates +the household's ``default_year``. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Annotated, Any, Self +from uuid import UUID + +from policyengine_simulation_contract.household_execution import Axes +from policyengine_simulation_contract.stage12_bundle import CountryId +from policyengine_simulation_persistence import ( + NewSimulation, + RunStatus, + RunTrigger, + SimulationKind, +) +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +if TYPE_CHECKING: + from policyengine_simulation_persistence import ( + HouseholdCalculation, + HouseholdReport, + ) + + +class StrictRouteModel(BaseModel): + """Base for the household routes' models: no unknown fields, finite numbers.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + +class HouseholdPolicyChoice(StrictRouteModel): + """A policy stored in the Public API, by ID, or none for current law.""" + + policy_id: UUID | None = None + + +class HouseholdCalculationOptions(StrictRouteModel): + """What else changes what a household calculation computes. + + ``axes`` vary inputs across points, and ``add_missing_variables`` adds + every yearly, monthly and eternal variable the household lacks, as the + Public API's ``/calculate-full`` does. The options are part of what a + calculation is, so a calculation is reused only for the same options. + """ + + axes: Axes = None + add_missing_variables: bool = False + + def stored(self) -> dict[str, Any]: + """Return the options a simulation stores: only those set, as JSON.""" + stored: dict[str, Any] = {} + if self.axes is not None: + stored["axes"] = [ + [axis.model_dump(mode="json") for axis in group] for group in self.axes + ] + if self.add_missing_variables: + stored["add_missing_variables"] = True + return stored + + +PolicyEngineVersion = Annotated[str, Field(min_length=1, max_length=128)] + + +class HouseholdCalculationSubmission(StrictRouteModel): + """One stored household under one policy, to calculate. + + ``household_id`` and ``policy_id`` (none for current law) name the + Public API's rows of ``country``; ``policyengine_version`` selects the + country's catalog version and the household function. The calculation + is for the household's ``default_year``. A calculation submitted again + reuses its run while it is unfinished or for 28 days after it succeeded, + unless ``force``; after that, it runs again under a new calculation ID. + """ + + country: CountryId + policyengine_version: PolicyEngineVersion + household_id: UUID + policy_id: UUID | None = None + options: HouseholdCalculationOptions = Field( + default_factory=HouseholdCalculationOptions + ) + force: bool = False + trigger: RunTrigger = RunTrigger.INITIAL + + def simulation_definition(self) -> NewSimulation: + """Return the household simulation this calculation runs.""" + return household_simulation_definition( + self.country, self.household_id, self.policy_id, self.options + ) + + +class HouseholdReportSubmission(StrictRouteModel): + """A household report to create: a stored household under a baseline and a reform. + + ``reform`` is omitted for a report of the baseline alone; each policy is + a stored policy's ID, or none for current law. Both are calculated with + the same ``options``, for the household's ``default_year``, in one Modal + call when neither has a run to reuse. The report's first run starts at + once. + """ + + country: CountryId + policyengine_version: PolicyEngineVersion + household_id: UUID + baseline: HouseholdPolicyChoice = Field(default_factory=HouseholdPolicyChoice) + reform: HouseholdPolicyChoice | None = None + options: HouseholdCalculationOptions = Field( + default_factory=HouseholdCalculationOptions + ) + force: bool = False + trigger: RunTrigger = RunTrigger.INITIAL + + def policy_ids(self) -> list[UUID | None]: + """Return the baseline's policy and, when there is a reform, its policy.""" + return [self.baseline.policy_id] + ( + [] if self.reform is None else [self.reform.policy_id] + ) + + def simulation_definition(self, policy_id: UUID | None) -> NewSimulation: + """Return the household simulation of one of the report's policies.""" + return household_simulation_definition( + self.country, self.household_id, policy_id, self.options + ) + + +class HouseholdReportRunSubmission(StrictRouteModel): + """Another run of a stored household report, on a catalog version. + + The report's household, policies, options and year are read from the + report; only the PolicyEngine.py version is chosen. The runs are reused + while unfinished or for 28 days after they succeeded, unless ``force``. + """ + + policyengine_version: PolicyEngineVersion + force: bool = False + trigger: RunTrigger = RunTrigger.MANUAL + + +def household_simulation_definition( + country: str, + household_id: UUID, + policy_id: UUID | None, + options: HouseholdCalculationOptions, +) -> NewSimulation: + """Return the household simulation of a household under a policy.""" + return NewSimulation( + kind=SimulationKind.HOUSEHOLD, + country=country, + household_id=household_id, + policy_id=policy_id, + options=options.stored(), + ) + + +class HouseholdCalculationResult(StrictRouteModel): + """A succeeded calculation's household, warnings, SPM choice and bundle. + + ``household`` keeps the request's people, groups and axes with each + requested value filled in, in the caller's key order. ``spm_config`` is + the complete SPM selection applied and ``spm_provenance`` its receipt, + both null for a country without SPM; ``bundle`` names the + PolicyEngine.py bundle the calculation ran with. + """ + + household: dict[str, JsonValue] + warnings: list[str] + spm_config: dict[str, JsonValue] | None + spm_provenance: dict[str, JsonValue] | None + bundle: dict[str, JsonValue] | None + + +class HouseholdCalculationFailure(StrictRouteModel): + """Why a calculation failed. + + ``code`` is the run's error code, such as ``household_calculation_failed`` + (the household function refused the inputs; ``detail`` holds its error, + with the Public API's error codes and messages), ``worker_error``, + ``worker_timeout`` or ``dispatch_abandoned`` (submit it again). + """ + + code: str + message: str + detail: dict[str, JsonValue] | None + + +class HouseholdCalculationResponse(StrictRouteModel): + """A household calculation's run: what it calculates, its status and outcome. + + ``calculation_id`` is the simulation run's ID. ``result`` is set once the + status is ``succeeded`` and ``error`` once it is ``failed``. + """ + + calculation_id: UUID + simulation_id: UUID + country: str + household_id: UUID + policy_id: UUID | None + options: dict[str, JsonValue] + catalog_version_id: UUID + year: int + attempt: int + trigger: RunTrigger + status: RunStatus + created_at: datetime + dispatched_at: datetime | None + completed_at: datetime | None + result: HouseholdCalculationResult | None + error: HouseholdCalculationFailure | None + + @classmethod + def from_calculation(cls, calculation: HouseholdCalculation) -> Self: + """Describe a stored household calculation.""" + run, simulation, output = ( + calculation.run, + calculation.simulation, + calculation.output, + ) + # The database requires a household simulation to name its household. + if simulation.household_id is None: # pragma: no cover + msg = "a household calculation's simulation has a household" + raise ValueError(msg) + return cls( + calculation_id=run.id, + simulation_id=simulation.id, + country=simulation.country, + household_id=simulation.household_id, + policy_id=simulation.policy_id, + options=simulation.options, + catalog_version_id=run.tax_benefit_model_version_id, + year=run.year, + attempt=run.attempt, + trigger=run.trigger, + status=run.status, + created_at=run.created_at, + dispatched_at=run.dispatched_at, + completed_at=run.completed_at, + result=( + None + if output is None + else HouseholdCalculationResult( + household=output.household, + warnings=list(output.warnings), + spm_config=output.spm_config, + spm_provenance=output.spm_provenance, + bundle=output.bundle, + ) + ), + error=( + None + if run.status is not RunStatus.FAILED + else HouseholdCalculationFailure( + code=run.error_code or "unknown", + message=run.error_message or "", + detail=run.error_detail, + ) + ), + ) + + +def household_report_status(report: HouseholdReport) -> RunStatus | None: + """Return a household report run's status, from its simulation runs. + + It has failed when either run failed and succeeded when both did; + otherwise it is the less advanced of the two. None before the first run. + """ + statuses = { + calculation.run.status + for calculation in (report.baseline, report.reform) + if calculation is not None + } + if not statuses: + return None + if RunStatus.FAILED in statuses: + return RunStatus.FAILED + for status in (RunStatus.PENDING, RunStatus.DISPATCHING, RunStatus.RUNNING): + if status in statuses: + return status + return RunStatus.SUCCEEDED + + +class HouseholdReportResponse(StrictRouteModel): + """A household report with one of its runs, and that run's two calculations. + + The run is the report's newest, unless the response answers the request + that started another. ``status`` is the run's status: failed when either + calculation failed, succeeded when both did, and otherwise the less + advanced of the two. The run fields are null before the first run; + ``reform`` is null for a report of the baseline alone. + """ + + report_id: UUID + country: str + year: int + household_id: UUID | None + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + report_run_id: UUID | None + catalog_version_id: UUID | None + trigger: RunTrigger | None + status: RunStatus | None + baseline: HouseholdCalculationResponse | None + reform: HouseholdCalculationResponse | None + + @classmethod + def from_report(cls, report: HouseholdReport) -> Self: + """Describe a stored household report and one of its runs.""" + report_run = report.report_run + baseline = ( + None + if report.baseline is None + else HouseholdCalculationResponse.from_calculation(report.baseline) + ) + return cls( + report_id=report.report.id, + country=report.report.country, + year=report.report.year, + household_id=None if baseline is None else baseline.household_id, + baseline_simulation_id=report.report.baseline_simulation_id, + reform_simulation_id=report.report.reform_simulation_id, + report_run_id=None if report_run is None else report_run.id, + catalog_version_id=( + None if report_run is None else report_run.tax_benefit_model_version_id + ), + trigger=None if report_run is None else report_run.trigger, + status=household_report_status(report), + baseline=baseline, + reform=( + None + if report.reform is None + else HouseholdCalculationResponse.from_calculation(report.reform) + ), + ) + + +class HouseholdRouteError(BaseModel): + """Why a household route refused or could not serve a request.""" + + detail: str + + +class HouseholdRequestFieldError(BaseModel): + """One request field FastAPI's validation refused, and why.""" + + loc: list[str | int] + msg: str + type: str + + +class HouseholdRefusalResponse(BaseModel): + """A refusal of the caller's data (422). + + ``detail`` is text when sim refuses the stored household or policy the + request names, such as a parameter the catalog version lacks, a policy + value or a household the household functions' contract refuses; and one + entry per field when FastAPI's validation refuses the request's fields. + """ + + detail: str | list[HouseholdRequestFieldError] diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py new file mode 100644 index 000000000..4537d792c --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_calculations.py @@ -0,0 +1,763 @@ +"""Submit household calculations, and read them as their Modal calls end. + +A household calculation is one simulation run of a household simulation (a +household under one policy, with its options) for a catalog version and +year. Submitting one: + +1. finds the household function the calculation's PolicyEngine.py version + deployed, in the v2 version manifest, before anything is written; +2. reads, from the Public API's tables, the country's catalog version for + that PolicyEngine.py version, the stored household (its document, saved + SPM choice and ``default_year``, the year calculated) and each policy's + parameter values by name, and checks every value against the household + functions' contract, all before anything is written. It refuses, in the + order it reads them: a catalog version or household the Public API does + not have, or a household without a default year (400); a stored + household the contract refuses (422, caller data); then, for each + policy, one the Public API does not have (400), or a parameter the + catalog version lacks or a value the contract refuses (422); +3. finds or creates the simulation, then reuses its run for that catalog + version and year (while unfinished, or for 28 days after it succeeded, + ``SUCCEEDED_RUN_REUSE_WINDOW``) or starts the next attempt; +4. starts the run's Modal call if the run is pending, whoever created it, so + a run another submitter left pending runs too. Only the submitter whose + claim moves the run to ``dispatching`` starts the call + (``RunDispatchService``). The household function reports when the call + ends, and sim records its outcome then; the safety check covers a call + that never reports and a run left ``dispatching``. + +Reading returns the run as it stands: status, error and, once it succeeded, +the household. Nothing waits for Modal. + +A household report compares the household under a baseline and a reform. +Creating one, or running a stored one again, starts or reuses a run of each +of its simulations, joins them in a report run, and starts one Modal call +for those still pending, so a new report's two households are calculated +together. When another submitter claimed one of them first, it starts that +one's call, and this submitter starts a call for the rest. Running a stored +report again reads its household and policies from the report's own +simulations. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol +from uuid import uuid4 + +from policyengine_simulation_contract.household_execution import ( + HouseholdDocument, + HouseholdExecutionRequest, + HouseholdPolicy, +) +from policyengine_simulation_persistence import ( + NewReport, + NewReportRun, + NewSimulationRun, + ReportKind, + RunStatus, +) +from policyengine_simulation_persistence.reports import FIRST_YEAR, LAST_YEAR +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError, ProgrammingError + +from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationOptions, +) +from policyengine_simulation_entry.household_conversion import ( + household_situation, + saved_spm_choice, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run +from policyengine_simulation_entry.modal_calls import ModalRequest +from policyengine_simulation_entry.run_dispatch import DispatchOutcome + +if TYPE_CHECKING: + from collections.abc import Callable, Collection, Mapping, Sequence + from uuid import UUID + + from policyengine_simulation_persistence import ( + HouseholdCalculation, + HouseholdCatalogVersion, + HouseholdReport, + NewSimulation, + ReportRecord, + ReportRunRecord, + RunTrigger, + SimulationRecord, + SimulationRunRecord, + StartedSimulationRun, + StoredHousehold, + ) + from pydantic import JsonValue + + from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationSubmission, + HouseholdReportRunSubmission, + HouseholdReportSubmission, + ) + from policyengine_simulation_entry.household_workers import HouseholdWorker + +UNKNOWN_REFERENCE_MESSAGE = ( + "The household, policy or catalog version does not exist for this country." +) + + +class UnknownHouseholdReferenceError(LookupError): + """The database refused a household, policy or catalog version reference. + + The rows were read before writing, so the Public API deleted one since. + """ + + +class HouseholdWithoutYearError(ValueError): + """The stored household has no default year in the runs' range to calculate.""" + + +class HouseholdDataRefusedError(ValueError): + """A stored household or policy value the household functions' contract refuses. + + These are problems with the caller's data: the message names the + household or policy and each refused field, and why. + """ + + +class PublicApiTablesUnreadableError(RuntimeError): + """The runtime role lacks ``SELECT`` on the Public API's tables sim reads.""" + + +# PostgreSQL's SQLSTATE for a missing privilege. +INSUFFICIENT_PRIVILEGE = "42501" +# A refusal names at most this many fields. +MAX_LISTED_REASONS = 10 + + +def _reasons(error: ValidationError) -> str: + """Return each refused field and why, as ``path: message``, at most ten.""" + items = error.errors() + shown = "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in items[:MAX_LISTED_REASONS] + ) + more = len(items) - MAX_LISTED_REASONS + return f"{shown}; and {more} more" if more > 0 else shown + + +class HouseholdCalculationRecords(Protocol): + """The ``HouseholdCalculationStore`` reads and writes a submission needs.""" + + def find_or_create_simulation( + self, new_simulation: NewSimulation + ) -> SimulationRecord: + """Return the household simulation with these inputs, or create it.""" + ... + + def start_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Reuse the run for this setup, or create the next attempt.""" + ... + + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation run ``run_id``, or None.""" + ... + + def get_simulation(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + ... + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Insert one report and return it.""" + ... + + def get_report(self, report_id: UUID) -> ReportRecord | None: + """Return the household report with this ID, or None.""" + ... + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Add a report run joining runs of the report's simulations.""" + ... + + def read_report( + self, report_id: UUID, *, report_run_id: UUID | None = None + ) -> HouseholdReport | None: + """Return a household report with a run and its two calculations.""" + ... + + +class SimulationRunStarter(Protocol): + """Start the one Modal call that computes some runs (``RunDispatchService``).""" + + async def start_simulation_runs( + self, run_ids: Collection[UUID], request: ModalRequest + ) -> DispatchOutcome: + """Claim the pending runs and start their call, once.""" + ... + + +class HouseholdRowReader(Protocol): + """Read the Public API's rows a household run needs (``HouseholdInputReader``).""" + + def catalog_version( + self, country: str, policyengine_version: str + ) -> HouseholdCatalogVersion: + """Return the country's catalog version for a PolicyEngine.py version.""" + ... + + def household(self, country: str, household_id: UUID) -> StoredHousehold: + """Return the country's stored household.""" + ... + + def policy_parameters( + self, country: str, policy_id: UUID, catalog_version_id: UUID + ) -> dict[str, dict[str, Any]]: + """Return the values the country's policy sets, by name and period.""" + ... + + +class HouseholdWorkerSource(Protocol): + """Find a country's household function (``ManifestHouseholdWorkers``).""" + + def resolve(self, country: str, policyengine_version: str) -> HouseholdWorker: + """Return the function and bundle, or raise why there is none.""" + ... + + +@dataclass(frozen=True) +class HouseholdRunInputs: + """What a household's runs calculate, read from the Public API's rows. + + ``year`` is the household's ``default_year``; ``parameters`` holds each + policy's values, by policy ID (None for current law, which changes + nothing). + """ + + country: str + catalog_version_id: UUID + year: int + household: HouseholdDocument + spm: JsonValue + spm_requested: bool + options: HouseholdCalculationOptions + parameters: Mapping[UUID | None, Mapping[str, object]] + + +def _calculable_household( + country: str, stored: StoredHousehold +) -> tuple[HouseholdDocument, int]: + """Return the stored household as the household functions take it, and its year. + + Raise HouseholdWithoutYearError when it has no default year in the runs' + range, and HouseholdDataRefusedError when the household functions' + contract refuses it, such as a group no person belongs to. + """ + year = stored.default_year + if year is None or not FIRST_YEAR <= year <= LAST_YEAR: + msg = f"the household {stored.id} has no default year to calculate" + raise HouseholdWithoutYearError(msg) + try: + document = HouseholdDocument.model_validate( + household_situation(country, stored.document) + ) + except ValidationError as error: + msg = f"the household {stored.id} cannot be calculated: {_reasons(error)}" + raise HouseholdDataRefusedError(msg) from None + except (KeyError, TypeError) as error: + msg = f"the household {stored.id} cannot be calculated: {error!r}" + raise HouseholdDataRefusedError(msg) from None + return document, year + + +def _checked_policy_parameters( + policy_id: UUID, parameters: Mapping[str, object] +) -> dict[str, dict[str, Any]]: + """Return a policy's values; raise HouseholdDataRefusedError for one refused. + + The values are checked as the household functions' contract checks a + policy, under the key its run will have. The refusal names the refused + parameters, at most ten. + """ + try: + return HouseholdPolicy.model_validate( + {"policy_key": policy_key_for_run(policy_id), "parameters": parameters} + ).parameters + except ValidationError as error: + refused = sorted( + { + str(item["loc"][1]) + for item in error.errors() + if len(item["loc"]) > 1 and item["loc"][0] == "parameters" + } + ) + if refused: + shown = ", ".join(refused[:MAX_LISTED_REASONS]) + more = len(refused) - MAX_LISTED_REASONS + reasons = f"{shown} and {more} more" if more > 0 else shown + else: + reasons = _reasons(error) + msg = ( + f"the policy {policy_id} sets values the household functions " + f"cannot take: {reasons}" + ) + raise HouseholdDataRefusedError(msg) from None + + +def read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calculates + reads: HouseholdRowReader, + *, + country: str, + household_id: UUID, + policy_ids: Sequence[UUID | None], + policyengine_version: str, + options: HouseholdCalculationOptions, +) -> HouseholdRunInputs: + """Read and check everything the household's runs calculate. + + Raise the reads' errors for a catalog version, household or policy the + Public API does not have or a parameter the version lacks, + HouseholdWithoutYearError for a household without a default year, + HouseholdDataRefusedError for a household or policy value the household + functions' contract refuses, and PublicApiTablesUnreadableError when the + runtime role cannot read the Public API's tables. + """ + try: + return _read_household_inputs( + reads, + country=country, + household_id=household_id, + policy_ids=policy_ids, + policyengine_version=policyengine_version, + options=options, + ) + except ProgrammingError as error: + if getattr(error.orig, "sqlstate", None) != INSUFFICIENT_PRIVILEGE: + raise + msg = ( + "The Simulation Entrypoint cannot read the Public API's tables: its " + "database role lacks SELECT on them." + ) + raise PublicApiTablesUnreadableError(msg) from error + + +def _read_household_inputs( # noqa: PLR0913 # one keyword per thing a run calculates + reads: HouseholdRowReader, + *, + country: str, + household_id: UUID, + policy_ids: Sequence[UUID | None], + policyengine_version: str, + options: HouseholdCalculationOptions, +) -> HouseholdRunInputs: + version = reads.catalog_version(country, policyengine_version) + stored = reads.household(country, household_id) + household, year = _calculable_household(country, stored) + spm = saved_spm_choice(stored.document) + parameters: dict[UUID | None, Mapping[str, object]] = {} + for policy_id in dict.fromkeys(policy_ids): + parameters[policy_id] = ( + {} + if policy_id is None + else _checked_policy_parameters( + policy_id, reads.policy_parameters(country, policy_id, version.id) + ) + ) + return HouseholdRunInputs( + country=country, + catalog_version_id=version.id, + year=year, + household=household, + spm=spm, + # A saved null selection is no choice, as the contract reads it. + spm_requested=spm is not None, + options=options, + parameters=parameters, + ) + + +def check_household_request( + worker: HouseholdWorker, inputs: HouseholdRunInputs +) -> None: + """Check the household function call these inputs make, before writing anything. + + The call is built as it will be sent, with placeholder IDs for the runs + not created yet, so whatever the contract refuses in the household, its + options, SPM choice or policies is refused now. Raise + HouseholdDataRefusedError naming each refused field. + """ + try: + household_modal_request( + worker, + calculation_id=uuid4(), + inputs=inputs, + run_policies=[ + (uuid4(), parameters) for parameters in inputs.parameters.values() + ], + ) + except ValidationError as error: + msg = f"the household and its policies cannot be calculated: {_reasons(error)}" + raise HouseholdDataRefusedError(msg) from None + + +def household_modal_request( + worker: HouseholdWorker, + *, + calculation_id: UUID, + inputs: HouseholdRunInputs, + run_policies: Sequence[tuple[UUID, Mapping[str, object]]], +) -> ModalRequest: + """Return the household function call that computes each run's policy. + + ``run_policies`` pairs each run with its policy's parameter values; the + request names each policy by its run, so the result maps back. The call's + second argument lists the runs, so its last step can report them. + """ + situation = inputs.household.to_situation() + stored_options = inputs.options.stored() + if "axes" in stored_options: + situation["axes"] = stored_options["axes"] + payload: dict[str, object] = { + "calculation_id": calculation_id, + "country": inputs.country, + # The household's default year, as a situation's period key: the + # period of added variables, and of values and axes without one. + "year": str(inputs.year), + "household": situation, + "policies": [ + {"policy_key": policy_key_for_run(run_id), "parameters": parameters} + for run_id, parameters in run_policies + ], + "add_missing_variables": inputs.options.add_missing_variables, + # The household's choice is the one it was saved with. + "spm_selection_source": "saved_household", + "bundle": worker.bundle, + } + if inputs.spm_requested: + payload["spm"] = inputs.spm + request = HouseholdExecutionRequest.model_validate(payload) + return ModalRequest( + target=worker.target, + arguments=( + request.model_dump(mode="json"), + [str(run_id) for run_id, _ in run_policies], + ), + ) + + +@dataclass +class HouseholdCalculationService: + """Submit household calculations and read them.""" + + store: HouseholdCalculationRecords + dispatch: SimulationRunStarter + workers: HouseholdWorkerSource + reads: HouseholdRowReader + + async def submit( + self, submission: HouseholdCalculationSubmission + ) -> HouseholdCalculation: + """Record the calculation, start its call if needed, and return it. + + Raise HouseholdFunctionUnavailableError or HouseholdWorkersUnavailableError, + the row reads' refusals and HouseholdDataRefusedError before anything + is written, and + UnknownHouseholdReferenceError when the database refuses a reference. + """ + worker = await asyncio.to_thread( + self.workers.resolve, submission.country, submission.policyengine_version + ) + inputs = await asyncio.to_thread( + lambda: read_household_inputs( + self.reads, + country=submission.country, + household_id=submission.household_id, + policy_ids=[submission.policy_id], + policyengine_version=submission.policyengine_version, + options=submission.options, + ) + ) + await asyncio.to_thread(check_household_request, worker, inputs) + + def start() -> StartedSimulationRun: + simulation = self.store.find_or_create_simulation( + submission.simulation_definition() + ) + return self.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=inputs.catalog_version_id, + year=inputs.year, + trigger=submission.trigger, + ), + force=submission.force, + ) + + run = (await self._write(start)).run + await self._start_pending_runs( + worker, + calculation_id=run.id, + inputs=inputs, + runs=[(run, inputs.parameters[submission.policy_id])], + ) + calculation = await self.read(run.id) + if calculation is None: # pragma: no cover - runs are never deleted + msg = f"household calculation {run.id} disappeared" + raise RuntimeError(msg) + return calculation + + async def read(self, calculation_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation, or None if there is none.""" + return await asyncio.to_thread(self.store.read_calculation, calculation_id) + + async def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReport: + """Create a household report and start its first run. + + The rows are read and the runs start before the report is created, so + a submission sim cannot calculate leaves no report behind. Raise as + ``submit`` does. + """ + worker = await asyncio.to_thread( + self.workers.resolve, submission.country, submission.policyengine_version + ) + policy_ids = submission.policy_ids() + inputs = await asyncio.to_thread( + lambda: read_household_inputs( + self.reads, + country=submission.country, + household_id=submission.household_id, + policy_ids=policy_ids, + policyengine_version=submission.policyengine_version, + options=submission.options, + ) + ) + await asyncio.to_thread(check_household_request, worker, inputs) + + def create() -> tuple[ReportRecord, list[SimulationRunRecord]]: + simulations = [ + self.store.find_or_create_simulation( + submission.simulation_definition(policy_id) + ) + for policy_id in policy_ids + ] + runs = self._start_side_runs( + simulations, + inputs=inputs, + trigger=submission.trigger, + force=submission.force, + ) + report = self.store.create_report( + NewReport( + kind=ReportKind.HOUSEHOLD, + country=submission.country, + baseline_simulation_id=simulations[0].id, + reform_simulation_id=( + simulations[1].id if len(simulations) > 1 else None + ), + year=inputs.year, + ) + ) + return report, runs + + report, runs = await self._write(create) + return await self._join_and_start( + worker, + report, + inputs=inputs, + sides=[ + (run, inputs.parameters[policy_id]) + for run, policy_id in zip(runs, policy_ids, strict=True) + ], + trigger=submission.trigger, + ) + + async def rerun_report( + self, report_id: UUID, submission: HouseholdReportRunSubmission + ) -> HouseholdReport | None: + """Start another run of a stored household report; None if there is none. + + The household, policies and options are the report's simulations'; + the rows are read again for the chosen catalog version. Raise as + ``submit`` does. + """ + + def load() -> tuple[ReportRecord, list[SimulationRecord]] | None: + report = self.store.get_report(report_id) + if report is None: + return None + simulation_ids = [report.baseline_simulation_id] + ( + [] + if report.reform_simulation_id is None + else [report.reform_simulation_id] + ) + simulations = [ + self.store.get_simulation(simulation_id) + for simulation_id in simulation_ids + ] + return report, [ + simulation for simulation in simulations if simulation is not None + ] + + loaded = await asyncio.to_thread(load) + if loaded is None: + return None + report, simulations = loaded + household_id = simulations[0].household_id + # The database requires a household simulation to name its household. + if household_id is None: # pragma: no cover + msg = f"household report {report.id} has no household" + raise RuntimeError(msg) + policy_ids = [simulation.policy_id for simulation in simulations] + worker = await asyncio.to_thread( + self.workers.resolve, report.country, submission.policyengine_version + ) + inputs = await asyncio.to_thread( + lambda: read_household_inputs( + self.reads, + country=report.country, + household_id=household_id, + policy_ids=policy_ids, + policyengine_version=submission.policyengine_version, + options=HouseholdCalculationOptions.model_validate( + simulations[0].options + ), + ) + ) + await asyncio.to_thread(check_household_request, worker, inputs) + # The report's year is its household's default year, which never + # changes, so the runs take the year the household was read with. + runs = await self._write( + lambda: self._start_side_runs( + simulations, + inputs=inputs, + trigger=submission.trigger, + force=submission.force, + ) + ) + return await self._join_and_start( + worker, + report, + inputs=inputs, + sides=[ + (run, inputs.parameters[policy_id]) + for run, policy_id in zip(runs, policy_ids, strict=True) + ], + trigger=submission.trigger, + ) + + async def read_report(self, report_id: UUID) -> HouseholdReport | None: + """Return a household report with its newest run, or None.""" + return await asyncio.to_thread(self.store.read_report, report_id) + + def _start_side_runs( + self, + simulations: Sequence[SimulationRecord], + *, + inputs: HouseholdRunInputs, + trigger: RunTrigger, + force: bool, + ) -> list[SimulationRunRecord]: + """Start or reuse a run of each side; a simulation on both sides runs once.""" + started: dict[UUID, SimulationRunRecord] = {} + for simulation in simulations: + if simulation.id not in started: + started[simulation.id] = self.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=inputs.catalog_version_id, + year=inputs.year, + trigger=trigger, + ), + force=force, + ).run + return [started[simulation.id] for simulation in simulations] + + async def _join_and_start( + self, + worker: HouseholdWorker, + report: ReportRecord, + *, + inputs: HouseholdRunInputs, + sides: Sequence[tuple[SimulationRunRecord, Mapping[str, object]]], + trigger: RunTrigger, + ) -> HouseholdReport: + """Join the sides' runs in a report run, and start those still pending.""" + report_run = await self._write( + lambda: self.store.create_report_run( + NewReportRun( + report_id=report.id, + baseline_simulation_run_id=sides[0][0].id, + reform_simulation_run_id=( + sides[1][0].id if len(sides) > 1 else None + ), + trigger=trigger, + ) + ) + ) + unique_runs = {run.id: (run, parameters) for run, parameters in sides} + await self._start_pending_runs( + worker, + calculation_id=report_run.id, + inputs=inputs, + runs=list(unique_runs.values()), + ) + read = await asyncio.to_thread( + lambda: self.store.read_report(report.id, report_run_id=report_run.id) + ) + if read is None: # pragma: no cover - reports are never deleted + msg = f"household report {report.id} disappeared" + raise RuntimeError(msg) + return read + + async def _start_pending_runs( + self, + worker: HouseholdWorker, + *, + calculation_id: UUID, + inputs: HouseholdRunInputs, + runs: Sequence[tuple[SimulationRunRecord, Mapping[str, object]]], + ) -> None: + """Start one call for the runs that are pending, whoever created them. + + The claim moves every run or none, so when another submitter claimed + one of them first, its call computes that one, and the others are + claimed again without it. + """ + pending = [ + (run.id, parameters) + for run, parameters in runs + if run.status is RunStatus.PENDING + ] + while pending: + outcome = await self.dispatch.start_simulation_runs( + [run_id for run_id, _ in pending], + household_modal_request( + worker, + calculation_id=calculation_id, + inputs=inputs, + run_policies=pending, + ), + ) + if outcome is not DispatchOutcome.NOT_CLAIMED: + return + still_pending = [] + for run_id, parameters in pending: + calculation = await self.read(run_id) + if calculation is not None and calculation.run.status is ( + RunStatus.PENDING + ): + still_pending.append((run_id, parameters)) + # A claim fails only when a run is no longer pending. + if len(still_pending) == len(pending): # pragma: no cover + return + pending = still_pending + + async def _write[T](self, operation: Callable[[], T]) -> T: + """Run database writes off the event loop, naming a refused reference.""" + try: + return await asyncio.to_thread(operation) + except IntegrityError as error: + raise UnknownHouseholdReferenceError(UNKNOWN_REFERENCE_MESSAGE) from error diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py new file mode 100644 index 000000000..c7ab6bdf8 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_conversion.py @@ -0,0 +1,65 @@ +"""Stored v2 households, in the situation shape the household functions take. + +A stored household (the Public API's ``households.household_data``) lists +its people and each group entity as records, each with an ``id`` and its +``values``; each person names, for every group collection, the ``id`` of the +group it belongs to. The household functions take the situation shape: +``people`` maps each person to their values, and each group collection, by +its plural, maps each group to its ``members`` and values. Records keep +their ``id`` as their name, so a calculated value maps back to its record. +The saved SPM choice is the document's ``spm``. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + + from pydantic import JsonValue + +# Each country's stored group collections and their plural in the situation, +# as the Public API's households service accepts them. +GROUP_COLLECTIONS: dict[str, dict[str, str]] = { + "us": { + "household": "households", + "family": "families", + "tax_unit": "tax_units", + "spm_unit": "spm_units", + "marital_unit": "marital_units", + }, + "uk": {"household": "households", "benunit": "benunits"}, +} + + +def household_situation(country: str, document: Mapping[str, Any]) -> dict[str, Any]: + """Return a stored household's people and groups in the situation shape. + + A group no person belongs to keeps an empty member list, which the + household functions' contract refuses. + """ + people = document["people"] + situation: dict[str, Any] = { + "people": {person["id"]: dict(person["values"]) for person in people} + } + for collection, plural in GROUP_COLLECTIONS[country].items(): + situation[plural] = { + group["id"]: { + "members": [ + person["id"] + for person in people + if person["memberships"].get(collection) == group["id"] + ], + **group["values"], + } + for group in document.get(collection, []) + } + return situation + + +def saved_spm_choice(document: Mapping[str, Any]) -> JsonValue: + """Return the household's saved SPM selection, or None when it has none.""" + return document.get("spm") diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py new file mode 100644 index 000000000..8186185c1 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_recording.py @@ -0,0 +1,136 @@ +"""Record a household function's outcome on the runs its call computes. + +One call computes one household calculation (one simulation run) or a +household report's baseline and reform (two runs sharing the call). The +request names each policy by its run (``policy_key_for_run``), so each +result maps back to its run. + +A ``HouseholdExecutionResult`` stores each run's calculated household, with +its warnings, SPM receipt, the applied SPM selection and the bundle, in the +transaction that marks the runs succeeded. A ``HouseholdExecutionFailure`` +(inputs the country model does not know, an unusable SPM selection, another +bundle, or a failure inside the calculation) fails the runs with +``household_calculation_failed``: its message names only the failure's code, +and the household error document is the run's error detail, which the +calculation's reader returns. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionFailure, + HouseholdExecutionResult, + parse_household_execution_outcome, +) +from policyengine_simulation_persistence import ( + HouseholdOutput, + RunError, + RunErrorCode, + write_household_output, +) + +from policyengine_simulation_entry.run_recording import CalculationFailedError + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from policyengine_simulation_contract.household_execution import ( + HouseholdPolicyResult, + ) + from policyengine_simulation_persistence import SimulationRunRecord + from sqlalchemy import Connection + + type OutputWriter = Callable[ + [Connection, SimulationRunRecord, HouseholdOutput], None + ] + + +def policy_key_for_run(run_id: UUID) -> str: + """Return the policy key that names a run's policy in a household request.""" + return f"run-{run_id.hex}" + + +def _household_output( + result: HouseholdExecutionResult, policy_result: HouseholdPolicyResult +) -> HouseholdOutput: + output = result.output + return HouseholdOutput( + schema_version=result.schema_version, + household=policy_result.household.model_dump(mode="json"), + warnings=policy_result.warnings, + spm_config=( + None + if output.spm_config is None + else output.spm_config.model_dump(mode="json") + ), + spm_provenance=( + None + if policy_result.spm_provenance is None + else policy_result.spm_provenance.model_dump(mode="json") + ), + bundle=output.bundle.model_dump(mode="json"), + ) + + +class HouseholdResultRecorder: + """Validate a household function's outcome and write each run's household.""" + + def __init__(self, *, write_output: OutputWriter = write_household_output) -> None: + """Write each run's household with ``write_output``.""" + self._write_output = write_output + + def validate(self, output: object) -> HouseholdExecutionResult: + """Return the successful outcome. + + Raise CalculationFailedError for a typed failure, and ValueError for + an output that is neither. + """ + outcome = parse_household_execution_outcome(output) + if isinstance(outcome, HouseholdExecutionFailure): + raise CalculationFailedError( + RunError( + RunErrorCode.HOUSEHOLD_CALCULATION_FAILED, + f"The household calculation failed: {outcome.error.code}.", + detail=outcome.error.model_dump(mode="json"), + ) + ) + return outcome + + def write( + self, + connection: Connection, + runs: tuple[SimulationRunRecord, ...], + result: object, + ) -> None: + """Write each run's household in the transaction that succeeds them. + + Raise CalculationFailedError, writing nothing, when the outcome has no + result for one of the runs: the output cannot describe those runs, so + they fail as an invalid result instead of waiting for it to change. + """ + if not isinstance(result, HouseholdExecutionResult): + msg = "a household result recorder writes household results only" + raise TypeError(msg) + by_policy_key = { + policy_result.policy_key: policy_result + for policy_result in result.output.results + } + outputs = [] + for run in runs: + policy_result = by_policy_key.get(policy_key_for_run(run.id)) + if policy_result is None: + raise CalculationFailedError( + RunError( + RunErrorCode.INVALID_RESULT, + "The household outcome has no result for one of the " + "call's runs.", + ) + ) + outputs.append((run, _household_output(result, policy_result))) + for run, output in outputs: + self._write_output(connection, run, output) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py new file mode 100644 index 000000000..bfdc6a22d --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_routes.py @@ -0,0 +1,411 @@ +"""The household routes: submit a calculation or a report, then poll it. + +- ``POST /v2/household-calculations`` (permission ``simulation:submit``) + records the calculation and starts its Modal call, answering 202 with the + calculation as it stands. Submitting the same calculation again returns + the same one while it is unfinished or for 28 days after it succeeded, + unless forced; after that, it runs again under a new calculation ID. +- ``GET /v2/household-calculations/{calculation_id}`` (``simulation:read``) + returns it: its status, and its household once it succeeded or its error + once it failed. Poll until the status is ``succeeded`` or ``failed``. +- ``POST /v2/household-reports`` (``simulation:submit``) creates a household + report, a baseline and an optional reform, and starts its first run; + ``POST /v2/household-reports/{report_id}/runs`` starts another run of it; + ``GET /v2/household-reports/{report_id}`` (``simulation:read``) returns it + with its newest run and that run's two calculations. + +A submission names the Public API's stored household and policies by ID and +the PolicyEngine.py version; sim reads the rows itself and calculates the +household's ``default_year``. Every route answers 503 with ``Retry-After`` +when household calculations are not configured or a dependency, such as the +database holding the Public API's rows, cannot be reached. A submission gets +400, with the reason, when it names a version or country without a +household function, a household, policy or catalog version the Public API +does not have, or when the stored household has no default year. It gets +422, with the reason, for the caller's data the household functions cannot +take, checked before anything is written: a policy changing a parameter the +catalog version lacks, or a stored policy value or household the household +functions' contract refuses, such as an object as a value or a group no +person belongs to. Without ``SELECT`` on the Public API's tables, the routes +answer 503 saying sim cannot read them. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse +from policyengine_simulation_persistence import ( + PolicyParametersMissingError, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, +) +from sqlalchemy.exc import SQLAlchemyError + +from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationResponse, + HouseholdCalculationSubmission, + HouseholdRefusalResponse, + HouseholdReportResponse, + HouseholdReportRunSubmission, + HouseholdReportSubmission, + HouseholdRouteError, +) +from policyengine_simulation_entry.household_calculations import ( + HouseholdDataRefusedError, + HouseholdWithoutYearError, + PublicApiTablesUnreadableError, + UnknownHouseholdReferenceError, +) +from policyengine_simulation_entry.household_workers import ( + HouseholdFunctionUnavailableError, + HouseholdWorkersUnavailableError, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from fastapi import FastAPI + from policyengine_simulation_persistence import ( + HouseholdCalculation, + HouseholdReport, + ) + + from policyengine_simulation_entry.auth import AuthorizationDependency + +logger = logging.getLogger(__name__) + +CALCULATIONS_PATH = "/v2/household-calculations" +REPORTS_PATH = "/v2/household-reports" +RETRY_AFTER_SECONDS = 10 +NOT_CONFIGURED = "Household calculations are not configured." +WORKERS_UNAVAILABLE = "The v2 version manifest could not be read." +DATABASE_UNAVAILABLE = "The simulation database could not be reached." + +REFUSED_RESPONSE = { + "description": ( + "Refused, with the reason in detail: no household function for this " + "version and country; a household, policy or catalog version the " + "Public API does not have for the country; or a stored household " + "without a default year" + ), + "model": HouseholdRouteError, +} +CALLER_DATA_REFUSED_RESPONSE = { + "description": ( + "The caller's data cannot be calculated, with the reason in detail " + "as text: a policy changing a parameter the catalog version lacks, or " + "a stored policy value or household the household functions' " + "contract refuses; or a request field is invalid (one entry per field)" + ), + "model": HouseholdRefusalResponse, +} +UNAVAILABLE_RESPONSE = { + "description": ( + "Household calculations are not configured, or a dependency could not " + "be reached, such as the v2 version manifest or the database holding " + "the simulation records and the Public API's rows, or sim's database " + "role cannot read the Public API's tables; retry after Retry-After " + "seconds" + ), + "model": HouseholdRouteError, +} + + +class HouseholdCalculations(Protocol): + """Submit and read household calculations (``HouseholdCalculationService``).""" + + async def submit( + self, submission: HouseholdCalculationSubmission + ) -> HouseholdCalculation: + """Record the calculation, start its call if needed, and return it.""" + ... + + async def read(self, calculation_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation, or None if there is none.""" + ... + + async def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReport: + """Create a household report and start its first run.""" + ... + + async def rerun_report( + self, report_id: UUID, submission: HouseholdReportRunSubmission + ) -> HouseholdReport | None: + """Start another run of a stored report; None if there is none.""" + ... + + async def read_report(self, report_id: UUID) -> HouseholdReport | None: + """Return a household report with its newest run, or None.""" + ... + + +def unavailable(detail: str) -> JSONResponse: + """Answer 503 with a wait before retrying.""" + return JSONResponse( + HouseholdRouteError(detail=detail).model_dump(), + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + headers={"Retry-After": str(RETRY_AFTER_SECONDS)}, + ) + + +def refused(detail: str, status_code: int) -> JSONResponse: + """Answer a refusal with its reason.""" + return JSONResponse( + HouseholdRouteError(detail=detail).model_dump(), status_code=status_code + ) + + +async def guarded[T](operation: Awaitable[T]) -> T | JSONResponse: + """Await ``operation``, answering a refusal or an unreachable dependency.""" + try: + return await operation + except ( + HouseholdFunctionUnavailableError, + UnknownHouseholdReferenceError, + UnknownHouseholdError, + UnknownPolicyError, + UnknownCatalogVersionError, + HouseholdWithoutYearError, + ) as error: + return refused(str(error), status.HTTP_400_BAD_REQUEST) + except (PolicyParametersMissingError, HouseholdDataRefusedError) as error: + return refused(str(error), status.HTTP_422_UNPROCESSABLE_ENTITY) + except HouseholdWorkersUnavailableError: + logger.warning("household_manifest_unavailable") + return unavailable(WORKERS_UNAVAILABLE) + except PublicApiTablesUnreadableError as error: + logger.error( # noqa: TRY400 # the log names the missing grant; a traceback adds nothing + "household_public_api_tables_unreadable", + extra={"error_type": type(error.__cause__).__name__}, + ) + return unavailable(str(error)) + except SQLAlchemyError as error: + logger.warning( + "household_database_unavailable", + extra={"error_type": type(error).__name__}, + ) + return unavailable(DATABASE_UNAVAILABLE) + + +def report_response(report: HouseholdReport, status_code: int) -> JSONResponse: + """Answer with the report and one of its runs, as they stand.""" + return JSONResponse( + HouseholdReportResponse.from_report(report).model_dump(mode="json"), + status_code=status_code, + ) + + +def calculation_response( + calculation: HouseholdCalculation, status_code: int +) -> JSONResponse: + """Answer with the calculation as it stands.""" + return JSONResponse( + HouseholdCalculationResponse.from_calculation(calculation).model_dump( + mode="json" + ), + status_code=status_code, + ) + + +def register_household_calculation_routes( + app: FastAPI, + calculations: HouseholdCalculations | None, + *, + submitter: AuthorizationDependency, + reader: AuthorizationDependency, +) -> None: + """Add the household calculation routes; without a service they answer 503.""" + router = APIRouter() + + @router.post( + CALCULATIONS_PATH, + summary="Submit a household calculation", + description=( + "Record a calculation of a stored household under one stored policy, " + "or current law, on the country's catalog version for a " + "PolicyEngine.py version, for the household's default year, and " + "start it. Poll the returned calculation_id until its status is " + "succeeded or failed." + ), + operation_id="submit_household_calculation", + status_code=status.HTTP_202_ACCEPTED, + response_model=HouseholdCalculationResponse, + responses={ + 400: REFUSED_RESPONSE, + 422: CALLER_DATA_REFUSED_RESPONSE, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(submitter)], + ) + async def submit_household_calculation( + body: HouseholdCalculationSubmission, + ) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + calculation = await guarded(calculations.submit(body)) + if isinstance(calculation, JSONResponse): + return calculation + return calculation_response(calculation, status.HTTP_202_ACCEPTED) + + @router.get( + f"{CALCULATIONS_PATH}/{{calculation_id}}", + summary="Get a household calculation", + description=( + "Return the calculation's status, and its household once it " + "succeeded or its error once it failed." + ), + operation_id="get_household_calculation", + response_model=HouseholdCalculationResponse, + responses={ + 404: {"description": "No such calculation", "model": HouseholdRouteError}, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(reader)], + ) + async def get_household_calculation(calculation_id: UUID) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + calculation = await guarded(calculations.read(calculation_id)) + if isinstance(calculation, JSONResponse): + return calculation + if calculation is None: + return refused("No such household calculation.", status.HTTP_404_NOT_FOUND) + return calculation_response(calculation, status.HTTP_200_OK) + + _add_report_routes(router, calculations, submitter=submitter, reader=reader) + app.include_router(router) + + +NO_REPORT = "No such household report." +NOT_FOUND_RESPONSE = {"description": NO_REPORT, "model": HouseholdRouteError} + + +def _add_report_routes( + router: APIRouter, + calculations: HouseholdCalculations | None, + *, + submitter: AuthorizationDependency, + reader: AuthorizationDependency, +) -> None: + """Add the household report routes to ``router``.""" + for add_route in (_add_report_creation, _add_report_run): + router.include_router(add_route(calculations, submitter)) + router.include_router(_add_report_read(calculations, reader)) + + +def _add_report_creation( + calculations: HouseholdCalculations | None, submitter: AuthorizationDependency +) -> APIRouter: + report_router = APIRouter() + + @report_router.post( + REPORTS_PATH, + summary="Create a household report", + description=( + "Create a household report (a stored household under a baseline and " + "an optional reform, each a stored policy or current law, for the " + "household's default year) and start its first run on the country's " + "catalog version for a PolicyEngine.py version. Poll the report until " + "its status is succeeded or failed." + ), + operation_id="submit_household_report", + status_code=status.HTTP_202_ACCEPTED, + response_model=HouseholdReportResponse, + responses={ + 400: REFUSED_RESPONSE, + 422: CALLER_DATA_REFUSED_RESPONSE, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(submitter)], + ) + async def submit_household_report( + body: HouseholdReportSubmission, + ) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + report = await guarded(calculations.submit_report(body)) + if isinstance(report, JSONResponse): + return report + return report_response(report, status.HTTP_202_ACCEPTED) + + return report_router + + +def _add_report_run( + calculations: HouseholdCalculations | None, submitter: AuthorizationDependency +) -> APIRouter: + report_router = APIRouter() + + @report_router.post( + f"{REPORTS_PATH}/{{report_id}}/runs", + summary="Run a household report again", + description=( + "Start another run of a stored household report on the catalog " + "version for a PolicyEngine.py version, reusing runs that are " + "unfinished or succeeded within the last 28 days unless forced. sim " + "reads the report's household and policies again. The answer " + "describes the new run." + ), + operation_id="run_household_report", + status_code=status.HTTP_202_ACCEPTED, + response_model=HouseholdReportResponse, + responses={ + 400: REFUSED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + 422: CALLER_DATA_REFUSED_RESPONSE, + 503: UNAVAILABLE_RESPONSE, + }, + dependencies=[Depends(submitter)], + ) + async def run_household_report( + report_id: UUID, body: HouseholdReportRunSubmission + ) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + report = await guarded(calculations.rerun_report(report_id, body)) + if isinstance(report, JSONResponse): + return report + if report is None: + return refused(NO_REPORT, status.HTTP_404_NOT_FOUND) + return report_response(report, status.HTTP_202_ACCEPTED) + + return report_router + + +def _add_report_read( + calculations: HouseholdCalculations | None, reader: AuthorizationDependency +) -> APIRouter: + report_router = APIRouter() + + @report_router.get( + f"{REPORTS_PATH}/{{report_id}}", + summary="Get a household report", + description=( + "Return the report with its newest run, that run's status, and its " + "baseline and reform calculations." + ), + operation_id="get_household_report", + response_model=HouseholdReportResponse, + responses={404: NOT_FOUND_RESPONSE, 503: UNAVAILABLE_RESPONSE}, + dependencies=[Depends(reader)], + ) + async def get_household_report(report_id: UUID) -> JSONResponse: + if calculations is None: + return unavailable(NOT_CONFIGURED) + report = await guarded(calculations.read_report(report_id)) + if isinstance(report, JSONResponse): + return report + if report is None: + return refused(NO_REPORT, status.HTTP_404_NOT_FOUND) + return report_response(report, status.HTTP_200_OK) + + return report_router diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py new file mode 100644 index 000000000..0a14c867c --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/household_workers.py @@ -0,0 +1,119 @@ +"""Find a country's household function in the v2 version manifest. + +The Public API names each calculation's catalog version and that version's +PolicyEngine.py version. The v2 version manifest (a Modal Dict that each v2 +deployment publishes to) maps the PolicyEngine.py version to its deployed v2 +worker application, which names each country's household function and the +bundle it runs. The calculation's request carries that bundle, and the +household function refuses any other. + +This is v2 code: the v1 ``run_simulation`` code does not use it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import modal +from policyengine_simulation_contract.household_execution import ( + HouseholdBundleProvenance, +) +from policyengine_simulation_contract.stage12_manifest import ( + V2_VERSION_MANIFEST_NAME, + V2ManifestLoader, +) + +from policyengine_simulation_entry.modal_calls import OWN_PROCESS_SIGNALS, ModalTarget + +if TYPE_CHECKING: + from policyengine_simulation_contract.stage12_manifest import ( + V2ManifestStore, + V2VersionManifest, + ) + + +class HouseholdFunctionUnavailableError(LookupError): + """The manifest has no household function for this country and version.""" + + +class HouseholdWorkersUnavailableError(RuntimeError): + """The manifest could not be read; ask again later.""" + + +@dataclass(frozen=True) +class HouseholdWorker: + """A country's deployed household function and the bundle it runs.""" + + target: ModalTarget + bundle: HouseholdBundleProvenance + + +class ManifestHouseholdWorkers: + """Resolve household functions from the v2 version manifest. + + Each resolution reads the manifest, which is small, so a newly published + version is found at once. ``V2ManifestLoader`` keeps its last valid copy + for a stored value that does not validate. + """ + + def __init__(self, loader: V2ManifestLoader) -> None: + """Read the manifest through ``loader``.""" + self._loader = loader + + @classmethod + def from_store(cls, store: V2ManifestStore) -> ManifestHouseholdWorkers: + """Read the manifest from ``store``.""" + return cls(V2ManifestLoader(store)) + + @classmethod + def from_modal_environment(cls, environment: str) -> ManifestHouseholdWorkers: + """Read the manifest from its Modal Dict in ``environment``. + + Nothing is read from Modal until the first resolution. + """ + return cls.from_store( + modal.Dict.from_name(V2_VERSION_MANIFEST_NAME, environment_name=environment) + ) + + def _manifest(self) -> V2VersionManifest: + try: + return self._loader.load() + except BaseException as error: + if isinstance(error, OWN_PROCESS_SIGNALS): + raise + msg = "the v2 version manifest could not be read" + raise HouseholdWorkersUnavailableError(msg) from error + + def resolve(self, country: str, policyengine_version: str) -> HouseholdWorker: + """Return ``country``'s household function in ``policyengine_version``. + + Raise HouseholdFunctionUnavailableError when the manifest has no such + version, or the version no household function for the country, and + HouseholdWorkersUnavailableError when the manifest cannot be read. + """ + worker = self._manifest().versions.get(policyengine_version) + if worker is None: + msg = ( + f"PolicyEngine.py {policyengine_version} has no deployed v2 worker " + "application" + ) + raise HouseholdFunctionUnavailableError(msg) + country_worker = worker.household_worker(country) + if country_worker is None or country_worker.household_callable is None: + msg = ( + f"PolicyEngine.py {policyengine_version} has no household function " + f"for country {country!r}" + ) + raise HouseholdFunctionUnavailableError(msg) + return HouseholdWorker( + target=ModalTarget( + application=worker.application_name, + function=country_worker.household_callable, + ), + bundle=HouseholdBundleProvenance.from_bundle( + worker.bundle, + bundle_manifest_sha256=worker.bundle_manifest_sha256, + country=country, + ), + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/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..da485ba11 --- /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 household calculation routes start +calls this way (``household_calculations``). +""" + +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..a1ff30382 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/run_recording.py @@ -0,0 +1,621 @@ +"""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, or one whose output reports + that its calculation failed, is marked ``failed`` with its error; a call + still running, or finished but of a kind this revision has + no recorder for, is checked again once its claim's lease ends. + +Pre-emption is never a failure: Modal re-runs a pre-empted call under the +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 CalculationFailedError(Exception): + """A finished call's output says its runs failed; record that failure. + + A result recorder's ``validate`` raises it for a valid failure document, + such as a household function's typed failure, and its ``write`` for an + output that cannot describe the runs it is written for. Nothing is + written, and the runs are failed with ``error``, its detail included. + """ + + def __init__(self, error: RunError) -> None: + """Keep the failure to record on the runs.""" + super().__init__(error.message) + self.error = error + + +class SimulationResultRecorder(Protocol): + """Validate one kind of simulation call's output and write its result rows.""" + + def validate(self, output: object) -> object: + """Return the validated result. + + Raise ValueError when it is invalid, and CalculationFailedError when + it validly reports that the calculation failed. + """ + ... + + 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 + + @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 CalculationFailedError as failure: + return await self._fail(handlers, failure.error) + except ValueError as error: + return await self._fail(handlers, _invalid_result(error)) + try: + completed = await asyncio.to_thread(steps.complete, result) + # The transaction rolled back; the output cannot describe the runs. + except CalculationFailedError as failure: + return await self._fail(handlers, failure.error) + # Writing the result rows failed, for example because the database + # was unreachable: nothing was recorded, and the call is checked again. + except Exception as error: + 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..d630ee44e --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/safety_check.py @@ -0,0 +1,126 @@ +"""The safety check: one recording pass, run as a Cloud Run job, then exit. + +A Modal call reports when it ends, and sim records its outcome then +(``call_report_route``). The safety check covers a call whose report never +arrived: Cloud Scheduler starts this job every ``SAFETY_CHECK_INTERVAL``, and +each execution runs one ``RunRecorder`` pass, which + +- fails runs and comparison steps left ``dispatching`` for ten minutes (sim + stopped between claiming them and recording their Modal call), so they can + be submitted again, and pending comparison steps whose run failed; +- asks Modal about running calls first dispatched, or last checked, at least + one interval ago, and records what it reports, with the same conditional + writes as a report. + +The job runs only the image of the revision that serves traffic: the deploy +updates it when that revision is promoted, and a rollback moves it back, so +a rejected candidate never records anything. Run it with +``python -m policyengine_simulation_entry.safety_check``; it reads +``SIMULATION_DATABASE_URL`` and sim's Modal token (``MODAL_TOKEN_ID``, +``MODAL_TOKEN_SECRET``). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from dataclasses import asdict +from typing import TYPE_CHECKING, Protocol + +from policyengine_simulation_observability.observability import ( + init_process_observability, +) + +from policyengine_simulation_entry.modal_calls import OWN_PROCESS_SIGNALS +from policyengine_simulation_entry.simulation_database import SimulationDatabase + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from policyengine_simulation_entry.run_recording import RecordingPass + +logger = logging.getLogger(__name__) + +EXIT_PASSED = 0 +EXIT_PASS_FAILED = 1 +EXIT_NOT_CONFIGURED = 2 + + +class 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.""" + ... + + +def _database_recorder(database_url: str) -> DueOutcomeRecorder: + return SimulationDatabase.from_url(database_url).call_recorder() + + +def main( + *, + environment: Mapping[str, str] | None = None, + build_recorder: Callable[[str], DueOutcomeRecorder] = _database_recorder, + 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. + """ + values = os.environ if environment is None else environment + telemetry = start_observability(values.get("APP_ENVIRONMENT", "local")) + try: + return _run_pass(values, build_recorder) + finally: + telemetry.shutdown() + + +def _run_pass( + values: Mapping[str, str], build_recorder: Callable[[str], DueOutcomeRecorder] +) -> int: + database_url = values.get("SIMULATION_DATABASE_URL", "") + if not database_url: + logger.error("safety_check_not_configured") + 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..f1bb964c9 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_database.py @@ -0,0 +1,96 @@ +"""The Simulation Entrypoint's connection to its own ``simulation`` schema.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from policyengine_simulation_persistence import ( + HouseholdCalculationStore, + HouseholdInputReader, + RunStore, + RunTransitions, + SimulationKind, + create_simulation_engine, + read_schema_revision, +) + +from policyengine_simulation_entry.household_calculations import ( + HouseholdCalculationService, +) +from policyengine_simulation_entry.household_recording import ( + HouseholdResultRecorder, +) +from policyengine_simulation_entry.household_workers import ManifestHouseholdWorkers +from policyengine_simulation_entry.modal_calls import ( + ModalCallDispatcher, + ModalCallInspector, +) +from policyengine_simulation_entry.run_dispatch import RunDispatchService +from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder + +if TYPE_CHECKING: + from sqlalchemy import Engine + + +class SimulationDatabase: + """Run short, blocking database operations off the event loop.""" + + def __init__(self, engine: Engine) -> None: + """Use ``engine``, which connects as the runtime role.""" + self.engine = engine + + @classmethod + def from_url(cls, database_url: str) -> SimulationDatabase: + """Build the runtime engine; no connection opens until first use. + + Raise ValueError, without echoing the URL, when it is malformed. + """ + return cls(create_simulation_engine(database_url)) + + def _read_revision(self) -> str | None: + with self.engine.connect() as connection: + return read_schema_revision(connection) + + async def read_revision(self) -> str | None: + """Return the applied migration revision, or None before the first one.""" + return await asyncio.to_thread(self._read_revision) + + def call_recorder(self) -> RunRecorder: + """Return what records a reported call's outcome on its runs. + + Household calls have a result recorder. A finished call of a kind + without one (economy runs and comparisons, until their path registers + them) is answered no_result_recorder and left for the safety check. + """ + return RunRecorder( + transitions=RunTransitions(self.engine), + runs=RunStore(self.engine), + inspector=ModalCallInspector(), + recorders=ResultRecorders( + simulations={SimulationKind.HOUSEHOLD: HouseholdResultRecorder()} + ), + ) + + def household_calculations( + self, modal_environment: str + ) -> HouseholdCalculationService: + """Return the household calculation service on this database. + + It finds household functions in the v2 version manifest of + ``modal_environment`` and starts their calls there. Nothing connects + until the first request. + """ + return HouseholdCalculationService( + store=HouseholdCalculationStore(self.engine), + reads=HouseholdInputReader(self.engine), + dispatch=RunDispatchService( + transitions=RunTransitions(self.engine), + dispatcher=ModalCallDispatcher(modal_environment), + ), + workers=ManifestHouseholdWorkers.from_modal_environment(modal_environment), + ) + + def dispose(self) -> None: + """Close every pooled connection.""" + self.engine.dispose() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py index c66929968..24abe5ff8 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_adapter.py @@ -26,10 +26,7 @@ Stage12CountryBundle, Stage12Dataset, ) - from policyengine_simulation_contract.stage12_manifest import ( - V2CountryWorker, - V2WorkerVersion, - ) + from policyengine_simulation_contract.stage12_manifest import V2WorkerVersion class ComparisonSkipReason(StrEnum): @@ -92,13 +89,6 @@ def _skip(reason: ComparisonSkipReason) -> AdaptedComparisonRun: return AdaptedComparisonRun(report=None, skip_reason=reason) -def _country_worker(worker: V2WorkerVersion, country: str) -> V2CountryWorker | None: - return next( - (item for item in worker.countries if item.country == country), - None, - ) - - def _options_skip_reason(payload: dict[str, Any]) -> ComparisonSkipReason | None: """Return why the request's fields or options are unsupported, if they are.""" if any( @@ -189,12 +179,16 @@ def adapt_annual_comparison( evaluation_id: UUID, worker: V2WorkerVersion, ) -> AdaptedComparisonRun: - """Convert only the reviewed production request shape without guessing.""" + """Convert only the reviewed production request shape without guessing. + + Only a country the manifest marks as economy-capable is converted; any + other country is skipped as unsupported. + """ skip_reason = _options_skip_reason(payload) if skip_reason is not None: return _skip(skip_reason) country = payload.get("country") - if not isinstance(country, str) or _country_worker(worker, country) is None: + if not isinstance(country, str) or worker.economy_worker(country) is None: return _skip(ComparisonSkipReason.UNSUPPORTED_COUNTRY) bundle_country = next( item for item in worker.bundle.countries if item.country == country diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py index 29dad7567..7c215d52b 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py @@ -20,7 +20,6 @@ ResultComparisonStatus, ) from policyengine_simulation_contract.stage12_manifest import ( - V2CountryWorker, V2ManifestLoader, V2WorkerVersion, ) @@ -99,12 +98,16 @@ class ReportOrigin: @dataclass(frozen=True) class PreparedReport: - """A comparison run ready to dispatch: its parent row, inputs and worker.""" + """A comparison run ready to dispatch: its parent row, inputs and worker. + + ``simulation_callable`` runs one of the report country's simulations; the + country is economy-capable, or the adapter would have skipped it. + """ parent: ComparisonReportRecord report: ReportExecutionInput worker_version: ResolvedWorkerVersion - country_worker: V2CountryWorker + simulation_callable: str class TemporaryStage12UnsupportedRequestError(ValueError): @@ -290,7 +293,6 @@ def _resolve_report( ) -> tuple[ ReportExecutionInput | None, ResolvedWorkerVersion, - V2CountryWorker, str | None, ]: worker_version = ResolvedWorkerVersion( @@ -302,21 +304,8 @@ def _resolve_report( evaluation_id=evaluation_id, worker=worker, ) - country = request_payload.get("country") - bundle_country = next( - (item for item in worker.bundle.countries if item.country == country), - worker.bundle.countries[0], - ) - country_worker = next( - item for item in worker.countries if item.country == bundle_country.country - ) skip_reason = adapted.skip_reason.value if adapted.skip_reason else None - return ( - adapted.report, - worker_version, - country_worker, - skip_reason, - ) + return adapted.report, worker_version, skip_reason def _parent_record( self, @@ -364,7 +353,7 @@ def _prepare_temporary_report( request_id: str, ) -> PreparedReport: evaluation_id = uuid4() - report, worker_version, country_worker, skip_reason = self._resolve_report( + report, worker_version, skip_reason = self._resolve_report( request_payload=request_payload, evaluation_id=evaluation_id, ) @@ -387,7 +376,9 @@ def _prepare_temporary_report( parent=parent, report=report, worker_version=worker_version, - country_worker=country_worker, + simulation_callable=worker_version.worker.economy_simulation_callable( + str(request_payload.get("country")) + ), ) def _prepare_automatic_report( @@ -432,14 +423,6 @@ def _prepare_automatic_report( }, ) return None - country = request_payload.get("country") - bundle_country = next( - (item for item in worker.bundle.countries if item.country == country), - worker.bundle.countries[0], - ) - country_worker = next( - item for item in worker.countries if item.country == bundle_country.country - ) parent = self._parent_record( evaluation_id=evaluation_id, origin=ReportOrigin( @@ -455,7 +438,9 @@ def _prepare_automatic_report( parent=parent, report=adapted.report, worker_version=worker_version, - country_worker=country_worker, + simulation_callable=worker.economy_simulation_callable( + str(request_payload.get("country")) + ), ) async def _spawn_report( @@ -517,9 +502,7 @@ async def _spawn_report( ), "worker_version": version, "modal_application": worker.application_name, - "simulation_callable": ( - prepared.country_worker.single_simulation_callable - ), + "simulation_callable": prepared.simulation_callable, "version_manifest_sha256": manifest_sha256, "bundle_manifest_sha256": worker.bundle_manifest_sha256, "artifact_prefix": artifact_prefix, 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/household_fakes.py b/projects/policyengine-simulation-entry/tests/household_fakes.py new file mode 100644 index 000000000..5c4b9dfaa --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/household_fakes.py @@ -0,0 +1,444 @@ +"""In-memory household simulations, calculations and Public API rows, for the tests. + +``FakeHouseholdStore`` keeps household simulations and run setups and leaves +each run's status to ``FakeRunTransitions``, so the dispatcher, the recorder +and the reader all see one copy of every run. It reuses runs by the same +rules as ``RunStore``: an unfinished run, or one that succeeded within the +reuse window, is reused unless forced; a failed one never is. + +``FakeHouseholdReads`` holds the Public API's rows the household path reads +(``HouseholdInputReader``): catalog versions, households and policies. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from policyengine_simulation_contract.stage12_manifest import publish_v2_manifest +from policyengine_simulation_entry.household_workers import ManifestHouseholdWorkers +from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, + HouseholdCalculation, + HouseholdCatalogVersion, + HouseholdReport, + PolicyParametersMissingError, + ReportKind, + ReportRecord, + ReportRunRecord, + RunStatus, + SimulationKind, + SimulationRecord, + SimulationRunRecord, + StartedSimulationRun, + StoredHousehold, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, +) +from sqlalchemy.exc import IntegrityError +from stage12_fixtures import worker + +if TYPE_CHECKING: + from datetime import datetime + + from policyengine_simulation_persistence import ( + HouseholdOutput, + NewReport, + NewReportRun, + NewSimulation, + NewSimulationRun, + RunTrigger, + ) + from run_fakes import FakeRunTransitions, ManualClock + +# The PolicyEngine.py version the fixture manifest's worker runs. +POLICYENGINE_VERSION = "5.2.0" +CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-00000000ca7a") +UK_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-00000000ca7b") +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-0000000000a1") +POLICY_ID = UUID("00000000-0000-4000-8000-0000000000b1") +DEFAULT_YEAR = 2026 +SPM_CHOICE = {"scenario": "ce_trend", "geography_kind": "national"} +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 3000} +} +# A one-person US household as the Public API stores it. +STORED_US_HOUSEHOLD = { + "people": [ + { + "id": "you", + "values": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}, + "memberships": { + "household": "your household", + "family": "your family", + "tax_unit": "your tax unit", + "spm_unit": "your spm unit", + "marital_unit": "your marital unit", + }, + } + ], + "household": [ + { + "id": "your household", + "values": { + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + }, + } + ], + "family": [{"id": "your family", "values": {}}], + "tax_unit": [{"id": "your tax unit", "values": {}}], + "spm_unit": [{"id": "your spm unit", "values": {}}], + "marital_unit": [{"id": "your marital unit", "values": {}}], +} +# The same household in the situation shape the household functions take. +US_SITUATION = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, + "households": { + "your household": { + "members": ["you"], + "state_name": {"2026": "CA"}, + "household_net_income": {"2026": None}, + } + }, + "families": {"your family": {"members": ["you"]}}, + "tax_units": {"your tax unit": {"members": ["you"]}}, + "spm_units": {"your spm unit": {"members": ["you"]}}, + "marital_units": {"your marital unit": {"members": ["you"]}}, +} + + +class FakeHouseholdReads: + """The Public API's catalog versions, households and policies, by country. + + It holds the stored US household (``HOUSEHOLD_ID``, with ``DEFAULT_YEAR``) + and the US CTC reform (``POLICY_ID``) on ``CATALOG_VERSION_ID``, the + catalog version of ``POLICYENGINE_VERSION``. ``missing_parameters`` names + a policy's parameters the run's version lacks; ``error`` fails every read. + """ + + def __init__(self) -> None: + """Hold the stored US household and reform.""" + self.catalog_versions = { + ("us", POLICYENGINE_VERSION): CATALOG_VERSION_ID, + ("uk", POLICYENGINE_VERSION): UK_CATALOG_VERSION_ID, + } + self.households = { + ("us", HOUSEHOLD_ID): StoredHousehold( + id=HOUSEHOLD_ID, + country="us", + default_year=DEFAULT_YEAR, + document=STORED_US_HOUSEHOLD, + ) + } + self.policies: dict[tuple[str, UUID], dict] = {("us", POLICY_ID): CTC_REFORM} + self.missing_parameters: dict[UUID, tuple[str, ...]] = {} + self.error: Exception | None = None + self.reads: list[str] = [] + + def _read(self, name: str) -> None: + self.reads.append(name) + if self.error is not None: + raise self.error + + def catalog_version( + self, country: str, policyengine_version: str + ) -> HouseholdCatalogVersion: + """Return the catalog version, or raise UnknownCatalogVersionError.""" + self._read("catalog_version") + version_id = self.catalog_versions.get((country, policyengine_version)) + if version_id is None: + msg = ( + f"{country} has no catalog version for PolicyEngine.py " + f"{policyengine_version}" + ) + raise UnknownCatalogVersionError(msg) + return HouseholdCatalogVersion( + id=version_id, country=country, policyengine_version=policyengine_version + ) + + def household(self, country: str, household_id: UUID) -> StoredHousehold: + """Return the household, or raise UnknownHouseholdError.""" + self._read("household") + household = self.households.get((country, household_id)) + if household is None: + msg = f"{country} has no household {household_id}" + raise UnknownHouseholdError(msg) + return household + + def policy_parameters( + self, country: str, policy_id: UUID, catalog_version_id: UUID + ) -> dict: + """Return the policy's values, or raise why the run cannot apply them.""" + del catalog_version_id + self._read("policy_parameters") + parameters = self.policies.get((country, policy_id)) + if parameters is None: + msg = f"{country} has no policy {policy_id}" + raise UnknownPolicyError(msg) + missing = self.missing_parameters.get(policy_id) + if missing: + raise PolicyParametersMissingError(missing) + return parameters + + def store_household(self, default_year: int | None, **document: object) -> None: + """Replace the stored US household's default year or document fields.""" + self.households[("us", HOUSEHOLD_ID)] = StoredHousehold( + id=HOUSEHOLD_ID, + country="us", + default_year=default_year, + document={**STORED_US_HOUSEHOLD, **document}, + ) + + +@dataclass(frozen=True) +class RunSetup: + """What a fake run runs: its simulation, catalog version, year and attempt.""" + + simulation_id: UUID + catalog_version_id: UUID + year: int + attempt: int + trigger: RunTrigger + + +class FakeHouseholdStore: + """Household simulations and run setups over ``FakeRunTransitions``' runs.""" + + def __init__(self, transitions: FakeRunTransitions, clock: ManualClock) -> None: + """Start with no simulations.""" + self.transitions = transitions + self.clock = clock + self.simulations: dict[UUID, SimulationRecord] = {} + self.setups: dict[UUID, RunSetup] = {} + self.outputs: dict[UUID, HouseholdOutput] = {} + # When each succeeded run's household was written, with its success. + self.succeeded_at: dict[UUID, datetime] = {} + self.reports: dict[UUID, ReportRecord] = {} + self.report_runs: list[ReportRunRecord] = [] + # Households the Public API does not have: creating their simulations + # breaks a foreign key. + self.unknown_households: set[UUID] = set() + # Catalog versions the Public API does not have: starting a run on one + # breaks a foreign key. + self.unknown_catalog_versions: set[UUID] = set() + + def find_or_create_simulation( + self, new_simulation: NewSimulation + ) -> SimulationRecord: + """Return the household simulation with these inputs, or create it.""" + if new_simulation.household_id in self.unknown_households: + statement = "INSERT INTO simulation.simulations" + raise IntegrityError( + statement, {}, LookupError(new_simulation.household_id) + ) + for simulation in self.simulations.values(): + if ( + simulation.country, + simulation.household_id, + simulation.policy_id, + simulation.dynamic_id, + simulation.options, + ) == ( + new_simulation.country, + new_simulation.household_id, + new_simulation.policy_id, + new_simulation.dynamic_id, + new_simulation.options, + ): + return simulation + created = SimulationRecord( + id=uuid4(), + kind=SimulationKind.HOUSEHOLD, + country=new_simulation.country, + policy_id=new_simulation.policy_id, + dynamic_id=new_simulation.dynamic_id, + household_id=new_simulation.household_id, + region_code=None, + dataset_name=None, + legacy_simulation_id=None, + options=dict(new_simulation.options), + created_at=self.clock(), + ) + self.simulations[created.id] = created + return created + + def start_run( + self, new_run: NewSimulationRun, *, force: bool = False + ) -> StartedSimulationRun: + """Reuse the newest attempt for this setup, or add the next one.""" + if new_run.tax_benefit_model_version_id in self.unknown_catalog_versions: + statement = "INSERT INTO simulation.simulation_runs" + raise IntegrityError( + statement, {}, LookupError(new_run.tax_benefit_model_version_id) + ) + attempts = [ + run_id + for run_id, setup in self.setups.items() + if (setup.simulation_id, setup.catalog_version_id, setup.year) + == ( + new_run.simulation_id, + new_run.tax_benefit_model_version_id, + new_run.year, + ) + ] + latest = max( + attempts, key=lambda run_id: self.setups[run_id].attempt, default=None + ) + if latest is not None and not force and self._reusable(latest): + return StartedSimulationRun(run=self.run_record(latest), created=False) + run_id = self.transitions.add_run("household", updated_at=self.clock()) + self.setups[run_id] = RunSetup( + simulation_id=new_run.simulation_id, + catalog_version_id=new_run.tax_benefit_model_version_id, + year=new_run.year, + attempt=1 if latest is None else self.setups[latest].attempt + 1, + trigger=new_run.trigger, + ) + return StartedSimulationRun(run=self.run_record(run_id), created=True) + + def _reusable(self, run_id: UUID) -> bool: + status = self.transitions.runs[run_id].status + if status is RunStatus.SUCCEEDED: + return self.succeeded_at[run_id] >= ( + self.clock() - SUCCEEDED_RUN_REUSE_WINDOW + ) + return status is not RunStatus.FAILED + + def run_record(self, run_id: UUID) -> SimulationRunRecord: + """Return the run as the database would, from its fake execution.""" + execution = self.transitions.runs[run_id] + setup = self.setups[run_id] + error = execution.error + finished = execution.status in {RunStatus.SUCCEEDED, RunStatus.FAILED} + return SimulationRunRecord( + id=run_id, + simulation_id=setup.simulation_id, + simulation_kind=SimulationKind.HOUSEHOLD, + tax_benefit_model_version_id=setup.catalog_version_id, + year=setup.year, + attempt=setup.attempt, + region_code=None, + dataset_id=None, + trigger=setup.trigger, + status=execution.status, + modal_call_id=execution.modal_call_id, + error_code=None if error is None else error.code.value, + error_message=None if error is None else error.message, + error_detail=None if error is None else error.stored_detail(), + check_count=execution.check_count, + dispatched_at=None, + next_check_at=execution.next_check_at, + completed_at=self.clock() if finished else None, + created_at=self.clock(), + updated_at=execution.updated_at or self.clock(), + ) + + def read_calculation(self, run_id: UUID) -> HouseholdCalculation | None: + """Return the household calculation run ``run_id``, or None.""" + if run_id not in self.setups: + return None + run = self.run_record(run_id) + return HouseholdCalculation( + run=run, + simulation=self.simulations[run.simulation_id], + output=( + self.outputs.get(run_id) if run.status is RunStatus.SUCCEEDED else None + ), + ) + + def get_simulation(self, simulation_id: UUID) -> SimulationRecord | None: + """Return the simulation with this ID, or None.""" + return self.simulations.get(simulation_id) + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Keep a new household report.""" + report = ReportRecord( + id=uuid4(), + kind=ReportKind(new_report.kind), + country=new_report.country, + baseline_simulation_id=new_report.baseline_simulation_id, + reform_simulation_id=new_report.reform_simulation_id, + year=new_report.year, + options=dict(new_report.options), + legacy_report_id=None, + created_at=self.clock(), + ) + self.reports[report.id] = report + return report + + def get_report(self, report_id: UUID) -> ReportRecord | None: + """Return the household report with this ID, or None.""" + return self.reports.get(report_id) + + def create_report_run(self, new_report_run: NewReportRun) -> ReportRunRecord: + """Keep a report run joining two of the fake's runs.""" + baseline = self.setups[new_report_run.baseline_simulation_run_id] + report_run = ReportRunRecord( + id=uuid4(), + report_id=new_report_run.report_id, + report_kind=ReportKind.HOUSEHOLD, + tax_benefit_model_version_id=baseline.catalog_version_id, + year=baseline.year, + baseline_simulation_run_id=new_report_run.baseline_simulation_run_id, + reform_simulation_run_id=new_report_run.reform_simulation_run_id, + trigger=new_report_run.trigger, + comparison_status=None, + comparison_modal_call_id=None, + comparison_error_code=None, + comparison_error_message=None, + comparison_error_detail=None, + comparison_check_count=0, + comparison_dispatched_at=None, + comparison_next_check_at=None, + comparison_completed_at=None, + created_at=self.clock(), + updated_at=self.clock(), + ) + self.report_runs.append(report_run) + return report_run + + def read_report( + self, report_id: UUID, *, report_run_id: UUID | None = None + ) -> HouseholdReport | None: + """Return a report with a run (its newest unless named) and both runs.""" + report = self.reports.get(report_id) + if report is None: + return None + runs = [ + report_run + for report_run in self.report_runs + if report_run.report_id == report_id + and report_run_id in {None, report_run.id} + ] + if not runs and report_run_id is not None: + return None + report_run = runs[-1] if runs else None + return HouseholdReport( + report=report, + report_run=report_run, + baseline=( + None + if report_run is None + else self.read_calculation(report_run.baseline_simulation_run_id) + ), + reform=( + None + if report_run is None or report_run.reform_simulation_run_id is None + else self.read_calculation(report_run.reform_simulation_run_id) + ), + ) + + def write_output(self, _connection, run, output: HouseholdOutput) -> None: + """Keep a run's household, as the result writer stores it.""" + self.outputs[run.id] = output + self.succeeded_at[run.id] = self.clock() + + +def manifest_workers() -> ManifestHouseholdWorkers: + """Return household workers resolved from a manifest of the fixture worker.""" + store: dict[str, object] = {} + publish_v2_manifest(store=store, worker=worker()) + return ManifestHouseholdWorkers.from_store(store) diff --git a/projects/policyengine-simulation-entry/tests/household_harness.py b/projects/policyengine-simulation-entry/tests/household_harness.py new file mode 100644 index 000000000..335e76564 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/household_harness.py @@ -0,0 +1,115 @@ +"""A household calculation service over fake runs, Modal, manifest and Public API rows. + +``Harness`` wires the real ``HouseholdCalculationService`` and +``RunRecorder`` to ``FakeHouseholdStore``, ``FakeRunTransitions``, a fake +dispatcher and inspector, the fixture manifest and ``FakeHouseholdReads``, +all on one clock. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from household_fakes import ( + HOUSEHOLD_ID, + POLICYENGINE_VERSION, + FakeHouseholdReads, + FakeHouseholdStore, + manifest_workers, +) +from policyengine_simulation_contract.call_reports import CallEndedReport +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, +) +from policyengine_simulation_entry.household_api_models import ( + HouseholdCalculationSubmission, +) +from policyengine_simulation_entry.household_calculations import ( + HouseholdCalculationService, +) +from policyengine_simulation_entry.household_recording import ( + HouseholdResultRecorder, +) +from policyengine_simulation_entry.run_dispatch import RunDispatchService +from policyengine_simulation_entry.run_recording import ResultRecorders, RunRecorder +from policyengine_simulation_persistence import SimulationKind +from run_fakes import FakeDispatcher, FakeInspector, FakeRunTransitions, ManualClock + +if TYPE_CHECKING: + from uuid import UUID + +EARNINGS_AXES = [ + [{"name": "employment_income", "count": 5, "min": 0, "max": 80_000}], +] + + +def submission(**overrides: object) -> HouseholdCalculationSubmission: + """Return a current-law calculation of the stored one-person US household.""" + values: dict[str, object] = { + "country": "us", + "policyengine_version": POLICYENGINE_VERSION, + "household_id": HOUSEHOLD_ID, + } + values.update(overrides) + return HouseholdCalculationSubmission.model_validate(values) + + +class Harness: + """A service over fake runs, Modal and manifest, with a recorder for reports.""" + + def __init__(self, dispatcher: FakeDispatcher | None = None) -> None: + """Build every part on one clock.""" + self.clock = ManualClock() + self.transitions = FakeRunTransitions(self.clock) + self.store = FakeHouseholdStore(self.transitions, self.clock) + self.dispatcher = dispatcher or FakeDispatcher() + self.inspector = FakeInspector() + self.reads = FakeHouseholdReads() + self.service = HouseholdCalculationService( + store=self.store, + reads=self.reads, + dispatch=RunDispatchService( + transitions=self.transitions, + dispatcher=self.dispatcher, + clock=self.clock, + ), + workers=manifest_workers(), + ) + self.recorder = RunRecorder( + transitions=self.transitions, + runs=self.transitions, + inspector=self.inspector, + recorders=ResultRecorders( + simulations={ + SimulationKind.HOUSEHOLD: HouseholdResultRecorder( + write_output=self.store.write_output + ) + } + ), + clock=self.clock, + ) + + def submit(self, **overrides: object): + """Submit a calculation and return it as it reads afterwards.""" + return asyncio.run(self.service.submit(submission(**overrides))) + + def read(self, run_id: UUID): + """Read a calculation.""" + return asyncio.run(self.service.read(run_id)) + + def report_call_ended(self, *run_ids: UUID) -> None: + """Deliver the call's report that it ended, as report_call_ended does.""" + call_id = self.transitions.runs[run_ids[0]].modal_call_id + assert call_id is not None + asyncio.run( + self.recorder.record_reported_call( + CallEndedReport(modal_call_id=call_id, simulation_run_ids=run_ids) + ) + ) + + def sent_request(self, position: int = -1) -> HouseholdExecutionRequest: + """Return a spawned call's household request.""" + return HouseholdExecutionRequest.model_validate( + self.dispatcher.requests[position].arguments[0] + ) diff --git a/projects/policyengine-simulation-entry/tests/household_support.py b/projects/policyengine-simulation-entry/tests/household_support.py new file mode 100644 index 000000000..21b16cb0c --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/household_support.py @@ -0,0 +1,129 @@ +"""Household calculation outcomes, as a household function returns them. + +The builders key each policy's result by its run (``policy_key_for_run``), as +the Simulation Entrypoint asks the household function to. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionFailure, + HouseholdExecutionResult, +) +from policyengine_simulation_contract.spm import ( + SPMRuntimeVersions, + SPMSelection, + build_spm_provenance, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run + +if TYPE_CHECKING: + from collections.abc import Sequence + +CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") +BUNDLE = { + "policyengine_version": "6.10.0", + "core_package_version": "3.33.0", + "country_package_name": "policyengine-us", + "country_package_version": "1.450.0", + "bundle_manifest_sha256": "a" * 64, +} +WORKER = { + "application_name": "policyengine-simulation-v2-py6-10-0", + "function_name": "calculate_household_us", +} +SPM_CONFIG = { + "forecast_content_sha256": "c" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +DEPRECATED_INPUT_WARNING = "The input `age_group` is deprecated and was ignored." + + +def spm_receipt() -> dict[str, Any]: + """Return the SPM receipt of a national, central-trend US calculation.""" + return build_spm_provenance( + forecast_id="forecast-2026", + forecast_sha256="c" * 64, + selection=SPMSelection.model_validate(SPM_CONFIG), + years=("2026",), + runtime_versions=SPMRuntimeVersions.model_validate( + { + "policyengine": "6.10.0", + "policyengine-core": "3.33.0", + "policyengine-us": "1.450.0", + "spm-calculator": "0.4.0", + } + ), + ).model_dump(mode="json") + + +def calculated_household(net_income: float) -> dict[str, Any]: + """Return a one-person US household with its net income calculated. + + Its group collection comes before ``people``, as a caller may send it. + """ + return { + "households": { + "your household": { + "members": ["you"], + "household_net_income": {"2026": net_income}, + } + }, + "people": {"you": {"age": {"2026": 40}}}, + } + + +def succeeded_outcome(run_ids: Sequence[UUID]) -> dict[str, Any]: + """Return a successful calculation with one result per run, in order. + + Each run's household has a net income of 1000 times its position plus one. + """ + return HouseholdExecutionResult.model_validate( + { + "calculation_id": CALCULATION_ID, + "output": { + "country": "us", + "bundle": BUNDLE, + "spm_config": SPM_CONFIG, + "results": [ + { + "policy_key": policy_key_for_run(run_id), + "household": calculated_household(1000.0 * (position + 1)), + "warnings": [DEPRECATED_INPUT_WARNING], + "spm_provenance": spm_receipt(), + } + for position, run_id in enumerate(run_ids) + ], + }, + "worker": WORKER, + } + ).model_dump(mode="json") + + +def failed_outcome() -> dict[str, Any]: + """Return a calculation the household function rejected for unknown inputs.""" + return HouseholdExecutionFailure.model_validate( + { + "calculation_id": CALCULATION_ID, + "error": { + "code": "invalid_inputs", + "message": "Unrecognized inputs: employment_incme", + "input_errors": [ + { + "type": "household_variable", + "name": "employment_incme", + "path": "people/you/employment_incme", + "message": "employment_incme is not a variable", + } + ], + }, + "worker": WORKER, + } + ).model_dump(mode="json") diff --git a/projects/policyengine-simulation-entry/tests/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..e8204249b --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/run_fakes.py @@ -0,0 +1,508 @@ +"""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 + run.error = RunError( + RunErrorCode.DISPATCH_ABANDONED, + "No Modal call was recorded for the dispatch in time.", + ) + failed_runs.append(run.id) + failed_comparisons = [] + for comparison in self.comparisons.values(): + 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/stage12_fixtures.py b/projects/policyengine-simulation-entry/tests/stage12_fixtures.py index fa4e83f6a..557e842a6 100644 --- a/projects/policyengine-simulation-entry/tests/stage12_fixtures.py +++ b/projects/policyengine-simulation-entry/tests/stage12_fixtures.py @@ -29,7 +29,12 @@ EVALUATION_ID = UUID("00000000-0000-0000-0000-000000000001") -def worker() -> V2WorkerVersion: +def worker(*, household_only: tuple[str, ...] = ()) -> V2WorkerVersion: + """Return a validated v2 worker for the US and the UK. + + A country in ``household_only`` has only a household function, so its + economy reports cannot run. + """ countries = [] country_workers = [] for country, package, package_version, dataset in ( @@ -60,10 +65,15 @@ def worker() -> V2WorkerVersion: ), ) ) + economy_capable = country not in household_only country_workers.append( V2CountryWorker( country=country, - single_simulation_callable=f"run_single_simulation_{country}", + economy_capable=economy_capable, + single_simulation_callable=( + f"run_single_simulation_{country}" if economy_capable else None + ), + household_callable=f"calculate_household_{country}", ) ) application_name = v2_application_name("5.2.0") @@ -86,8 +96,13 @@ def worker() -> V2WorkerVersion: validated_at=datetime(2026, 9, 14, tzinfo=UTC), validation_invocation_id="validation-1", country_validation_invocation_ids={ - "us": "validation-us", - "uk": "validation-uk", + item.country: f"validation-{item.country}" + for item in country_workers + if item.economy_capable + }, + household_validation_invocation_ids={ + item.country: f"household-validation-{item.country}" + for item in country_workers }, ), ) diff --git a/projects/policyengine-simulation-entry/tests/test_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..d1906bca4 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -198,8 +198,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 +210,9 @@ def _assert_beta_then_prod_release( def _assert_deployment_environment_settings(reusable_workflow: str) -> None: assert ( - # Sentinel, restore and final routing acknowledgement use secrets too. - reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 15 + # Sentinel, restore, final routing acknowledgement, the simulation + # database migration and the staging household test use secrets too. + reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 17 ) assert "APP_ENVIRONMENT: ${{ inputs.deployment_environment }}" in reusable_workflow assert "STAGE12_ENABLED_VALUE: ${{ vars.STAGE12_ENABLED }}" in reusable_workflow diff --git a/projects/policyengine-simulation-entry/tests/test_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_promotion.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py index 30f3b9eda..124f8c49c 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py @@ -8,7 +8,10 @@ DEPLOY_WORKFLOW, DEPLOYMENT_READY_SCRIPT, REUSABLE_DEPLOY_WORKFLOW, + REUSABLE_DEPLOY_WORKFLOW_REFERENCE, STAGE12_DEPLOY_WORKFLOW, + STAGING_PROMOTION_FIXTURES, + WORKFLOWS_DIRECTORY, job_section, sentinel_check_step, workflow_jobs, @@ -267,3 +270,78 @@ def test_deployed_stack_must_reproduce_the_numerical_sentinel(): for index, name in enumerate(names) if name.startswith("Restore previous revision") ) + + +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_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_household_calculations.py b/projects/policyengine-simulation-entry/tests/test_household_calculations.py new file mode 100644 index 000000000..4a310dccd --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_calculations.py @@ -0,0 +1,382 @@ +"""Submitting household calculations, and reading them as their calls end. + +A submission names the stored household, the policy and the PolicyEngine.py +version; sim reads the household, the policy's values and the catalog +version itself, and calculates the household's ``default_year``. +""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta + +import pytest +from household_fakes import ( + CATALOG_VERSION_ID, + CTC_REFORM, + DEFAULT_YEAR, + HOUSEHOLD_ID, + POLICY_ID, + POLICYENGINE_VERSION, + SPM_CHOICE, + US_SITUATION, +) +from household_harness import EARNINGS_AXES, Harness, submission +from household_support import calculated_household, succeeded_outcome +from policyengine_simulation_contract.stage12_manifest import v2_application_name +from policyengine_simulation_entry.household_calculations import ( + HouseholdDataRefusedError, + HouseholdWithoutYearError, + UnknownHouseholdReferenceError, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run +from policyengine_simulation_entry.household_workers import ( + HouseholdFunctionUnavailableError, +) +from policyengine_simulation_entry.modal_calls import ( + CallFailed, + CallSucceeded, + ModalTarget, +) +from policyengine_simulation_persistence import ( + SUCCEEDED_RUN_REUSE_WINDOW, + NewSimulationRun, + PolicyParametersMissingError, + RunError, + RunErrorCode, + RunStatus, + RunTrigger, + UnknownCatalogVersionError, + UnknownHouseholdError, + UnknownPolicyError, +) +from run_fakes import FakeDispatcher +from sqlalchemy.exc import OperationalError + + +def test_a_submitted_calculation_starts_one_household_call() -> None: + harness = Harness() + harness.reads.store_household(DEFAULT_YEAR, spm=SPM_CHOICE) + + calculation = harness.submit( + policy_id=str(POLICY_ID), + options={"axes": EARNINGS_AXES, "add_missing_variables": True}, + ) + + assert calculation.run.status is RunStatus.RUNNING + assert calculation.run.tax_benefit_model_version_id == CATALOG_VERSION_ID + assert calculation.run.year == DEFAULT_YEAR + assert calculation.simulation.policy_id == POLICY_ID + assert calculation.simulation.options == { + "axes": EARNINGS_AXES, + "add_missing_variables": True, + } + (spawned,) = harness.dispatcher.requests + assert spawned.target == ModalTarget( + application=v2_application_name(POLICYENGINE_VERSION), + function="calculate_household_us", + ) + assert spawned.arguments[1] == [str(calculation.run.id)] + request = harness.sent_request() + assert request.calculation_id == calculation.run.id + assert [policy.policy_key for policy in request.policies] == [ + policy_key_for_run(calculation.run.id) + ] + assert request.policies[0].parameters == CTC_REFORM + situation = request.household.to_situation() + assert {key: value for key, value in situation.items() if key != "axes"} == ( + US_SITUATION + ) + assert situation["axes"] == EARNINGS_AXES + assert request.add_missing_variables + assert request.spm_requested + assert request.spm == SPM_CHOICE + assert request.spm_selection_source == "saved_household" + assert request.bundle.policyengine_version == POLICYENGINE_VERSION + + +def test_a_household_without_an_spm_choice_inherits_the_defaults() -> None: + harness = Harness() + + calculation = harness.submit() + + assert "spm" not in harness.dispatcher.requests[0].arguments[0] + assert not harness.sent_request().spm_requested + assert calculation.simulation.options == {} + assert calculation.simulation.policy_id is None + + +def test_submitting_again_reuses_the_running_calculation() -> None: + harness = Harness() + + first = harness.submit() + again = harness.submit() + + assert again.run.id == first.run.id + assert len(harness.dispatcher.requests) == 1 + + +def test_a_pending_calculation_left_by_another_submitter_is_dispatched() -> None: + harness = Harness() + simulation = harness.store.find_or_create_simulation( + submission().simulation_definition() + ) + left_pending = harness.store.start_run( + NewSimulationRun( + simulation_id=simulation.id, + tax_benefit_model_version_id=CATALOG_VERSION_ID, + year=2026, + trigger=RunTrigger.INITIAL, + ) + ).run + + calculation = harness.submit() + + assert calculation.run.id == left_pending.id + assert calculation.run.status is RunStatus.RUNNING + assert len(harness.dispatcher.requests) == 1 + + +def test_an_unsupported_version_writes_nothing() -> None: + harness = Harness() + + with pytest.raises(HouseholdFunctionUnavailableError): + harness.submit(policyengine_version="9.9.9") + + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_a_household_deleted_while_submitting_is_refused_without_a_call() -> None: + harness = Harness() + harness.store.unknown_households.add(HOUSEHOLD_ID) + + with pytest.raises(UnknownHouseholdReferenceError): + harness.submit() + + assert harness.dispatcher.requests == [] + + +@pytest.mark.parametrize( + ("overrides", "error"), + [ + ( + {"household_id": "00000000-0000-4000-8000-00000000dead"}, + UnknownHouseholdError, + ), + ({"policy_id": "00000000-0000-4000-8000-00000000dead"}, UnknownPolicyError), + ({"country": "uk"}, UnknownHouseholdError), + ], + ids=["household", "policy", "another-country"], +) +def test_a_household_or_policy_the_public_api_lacks_writes_nothing( + overrides, error +) -> None: + harness = Harness() + + with pytest.raises(error): + harness.submit(**overrides) + + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_a_version_without_a_catalog_version_writes_nothing() -> None: + harness = Harness() + del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] + + with pytest.raises(UnknownCatalogVersionError, match=POLICYENGINE_VERSION): + harness.submit() + + assert harness.store.simulations == {} + + +def test_a_parameter_the_catalog_version_lacks_writes_nothing() -> None: + harness = Harness() + harness.reads.missing_parameters[POLICY_ID] = ( + "gov.irs.credits.retired_credit.amount", + ) + + with pytest.raises(PolicyParametersMissingError, match="retired_credit"): + harness.submit(policy_id=str(POLICY_ID)) + + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_a_household_without_a_default_year_is_not_calculable() -> None: + harness = Harness() + harness.reads.store_household(None) + + with pytest.raises(HouseholdWithoutYearError, match="default year"): + harness.submit() + + assert harness.store.simulations == {} + + +def test_a_stored_household_the_contract_refuses_is_not_calculable() -> None: + harness = Harness() + harness.reads.store_household( + DEFAULT_YEAR, + family=[{"id": "your family", "values": {}}, {"id": "empty", "values": {}}], + ) + + with pytest.raises(HouseholdDataRefusedError, match="empty"): + harness.submit() + + assert harness.store.simulations == {} + + +@pytest.mark.parametrize( + "value", [{"nested": 1}, [[1, 2]]], ids=["object", "nested-list"] +) +def test_a_stored_policy_value_the_contract_refuses_writes_nothing(value) -> None: + harness = Harness() + harness.reads.policies[("us", POLICY_ID)] = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": value} + } + + with pytest.raises(HouseholdDataRefusedError, match=str(POLICY_ID)): + harness.submit(policy_id=str(POLICY_ID)) + + assert harness.store.simulations == {} + assert harness.store.setups == {} + assert harness.dispatcher.requests == [] + + +def test_an_unreadable_public_api_row_is_reported_as_unavailable() -> None: + harness = Harness() + harness.reads.error = OperationalError("SELECT", {}, ConnectionError("down")) + + with pytest.raises(OperationalError): + harness.submit() + + assert harness.store.simulations == {} + + +def test_a_refused_call_reads_back_failed() -> None: + harness = Harness(FakeDispatcher(failure=RuntimeError("Modal refused"))) + + calculation = harness.submit() + + assert calculation.run.status is RunStatus.FAILED + assert calculation.run.error_code == "dispatch_failed" + + +def test_a_calculation_is_recorded_when_its_call_reports_success() -> None: + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([submitted.run.id]) + ) + + harness.report_call_ended(submitted.run.id) + + calculation = harness.read(submitted.run.id) + assert calculation.run.status is RunStatus.SUCCEEDED + assert calculation.output.household == calculated_household(1000.0) + + +def test_a_succeeded_calculation_runs_again_only_when_forced() -> None: + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([submitted.run.id]) + ) + harness.report_call_ended(submitted.run.id) + + reused = harness.submit() + forced = harness.submit(force=True) + + assert reused.run.id == submitted.run.id + assert forced.run.attempt == 2 + assert forced.run.status is RunStatus.RUNNING + assert len(harness.dispatcher.requests) == 2 + + +def test_a_failed_modal_call_reads_back_failed() -> None: + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallFailed( + RunError(RunErrorCode.WORKER_ERROR, "The Modal call raised MemoryError.") + ) + + harness.report_call_ended(submitted.run.id) + + calculation = harness.read(submitted.run.id) + assert calculation.run.status is RunStatus.FAILED + assert calculation.run.error_code == "worker_error" + assert calculation.output is None + + +class StoppingDispatcher(FakeDispatcher): + """Stop the instance after it claimed the run, before Modal answers.""" + + async def spawn(self, request): + """Record the request, then stop as a stopped instance does.""" + self.requests.append(request) + raise asyncio.CancelledError + + +def test_a_calculation_left_dispatching_fails_and_runs_again() -> None: + harness = Harness(StoppingDispatcher()) + with pytest.raises(asyncio.CancelledError): + harness.submit() + (stopped_run_id,) = harness.store.setups + assert harness.transitions.runs[stopped_run_id].status is RunStatus.DISPATCHING + + harness.clock.advance(timedelta(minutes=11)) + asyncio.run(harness.recorder.record_due_outcomes()) + + abandoned = harness.read(stopped_run_id) + assert abandoned.run.status is RunStatus.FAILED + assert abandoned.run.error_code == "dispatch_abandoned" + harness.dispatcher = FakeDispatcher() + harness.service.dispatch.dispatcher = harness.dispatcher + resubmitted = harness.submit() + assert resubmitted.run.id != stopped_run_id + assert resubmitted.run.attempt == 2 + assert resubmitted.run.status is RunStatus.RUNNING + + +def test_a_saved_null_spm_choice_is_no_choice() -> None: + """A stored household's null selection inherits the defaults, as omission does.""" + harness = Harness() + harness.reads.store_household(DEFAULT_YEAR, spm=None) + + harness.submit() + + assert "spm" not in harness.dispatcher.requests[0].arguments[0] + assert not harness.sent_request().spm_requested + + +def test_a_calculation_that_succeeded_too_long_ago_runs_again() -> None: + """Runs are reused only while their output files are kept.""" + harness = Harness() + submitted = harness.submit() + call_id = harness.transitions.runs[submitted.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([submitted.run.id]) + ) + harness.report_call_ended(submitted.run.id) + + harness.clock.advance(SUCCEEDED_RUN_REUSE_WINDOW + timedelta(days=1)) + again = harness.submit() + + assert again.run.id != submitted.run.id + assert again.run.attempt == 2 + + +def test_a_household_is_calculated_for_its_default_year_not_the_current_one() -> None: + harness = Harness() + harness.reads.store_household(2023) + + calculation = harness.submit(options={"add_missing_variables": True}) + + assert calculation.run.year == 2023 + request = harness.sent_request() + assert request.year == "2023" + assert request.add_missing_variables diff --git a/projects/policyengine-simulation-entry/tests/test_household_conversion.py b/projects/policyengine-simulation-entry/tests/test_household_conversion.py new file mode 100644 index 000000000..7e04c7343 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_conversion.py @@ -0,0 +1,116 @@ +"""Stored v2 households, in the situation shape the household functions take. + +These cases come from the Public API, which converted households before the +Simulation Entrypoint read them itself. +""" + +from __future__ import annotations + +from policyengine_simulation_entry.household_conversion import ( + household_situation, + saved_spm_choice, +) + +US_HOUSEHOLD = { + "people": [ + { + "id": "person-1", + "source_name": "you", + "values": {"age": {"2026": 40}, "employment_income": {"2026": 30000}}, + "memberships": { + "household": "household-1", + "family": "family-1", + "tax_unit": "tax_unit-1", + "spm_unit": "spm_unit-1", + "marital_unit": "marital_unit-1", + }, + }, + { + "id": "person-2", + "values": {"age": {"2026": 8}}, + "memberships": { + "household": "household-1", + "family": "family-1", + "tax_unit": "tax_unit-1", + "spm_unit": "spm_unit-1", + "marital_unit": "marital_unit-2", + }, + }, + ], + "household": [{"id": "household-1", "values": {"state_name": {"2026": "CA"}}}], + "family": [{"id": "family-1", "values": {}}], + "tax_unit": [{"id": "tax_unit-1", "values": {"eitc": {"2026": None}}}], + "spm_unit": [{"id": "spm_unit-1", "values": {}}], + "marital_unit": [ + {"id": "marital_unit-1", "values": {}}, + {"id": "marital_unit-2", "values": {}}, + ], + "spm": {"scenario": "ce_trend", "geography_kind": "national"}, +} +UK_HOUSEHOLD = { + "people": [ + { + "id": "person-1", + "values": {"age": {"2026": 30}}, + "memberships": {"household": "household-1", "benunit": "benunit-1"}, + } + ], + "household": [{"id": "household-1", "values": {"region": {"2026": "WALES"}}}], + "benunit": [{"id": "benunit-1", "values": {"universal_credit": {"2026": None}}}], +} + + +def test_a_us_household_becomes_a_situation_keyed_by_record_id() -> None: + assert household_situation("us", US_HOUSEHOLD) == { + "people": { + "person-1": {"age": {"2026": 40}, "employment_income": {"2026": 30000}}, + "person-2": {"age": {"2026": 8}}, + }, + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "state_name": {"2026": "CA"}, + } + }, + "families": {"family-1": {"members": ["person-1", "person-2"]}}, + "tax_units": { + "tax_unit-1": {"members": ["person-1", "person-2"], "eitc": {"2026": None}} + }, + "spm_units": {"spm_unit-1": {"members": ["person-1", "person-2"]}}, + "marital_units": { + "marital_unit-1": {"members": ["person-1"]}, + "marital_unit-2": {"members": ["person-2"]}, + }, + } + + +def test_a_uk_household_has_households_and_benefit_units() -> None: + assert household_situation("uk", UK_HOUSEHOLD) == { + "people": {"person-1": {"age": {"2026": 30}}}, + "households": { + "household-1": {"members": ["person-1"], "region": {"2026": "WALES"}} + }, + "benunits": { + "benunit-1": { + "members": ["person-1"], + "universal_credit": {"2026": None}, + } + }, + } + + +def test_a_group_without_members_keeps_an_empty_member_list() -> None: + household = {**UK_HOUSEHOLD, "benunit": [*UK_HOUSEHOLD["benunit"]]} + household["benunit"].append({"id": "benunit-2", "values": {}}) + + situation = household_situation("uk", household) + + assert situation["benunits"]["benunit-2"] == {"members": []} + + +def test_the_saved_spm_choice_is_the_documents_selection_or_none() -> None: + assert saved_spm_choice(US_HOUSEHOLD) == { + "scenario": "ce_trend", + "geography_kind": "national", + } + assert saved_spm_choice(UK_HOUSEHOLD) is None diff --git a/projects/policyengine-simulation-entry/tests/test_household_recording.py b/projects/policyengine-simulation-entry/tests/test_household_recording.py new file mode 100644 index 000000000..fed86a114 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_recording.py @@ -0,0 +1,221 @@ +"""Recording a household function's outcome on the runs its call computes.""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import pytest +from household_support import ( + BUNDLE, + DEPRECATED_INPUT_WARNING, + SPM_CONFIG, + calculated_household, + failed_outcome, + spm_receipt, + succeeded_outcome, +) +from policyengine_simulation_contract.call_reports import ( + CallEndedReport, + CallReportStatus, +) +from policyengine_simulation_entry.household_recording import ( + HouseholdResultRecorder, + policy_key_for_run, +) +from policyengine_simulation_entry.modal_calls import CallSucceeded +from policyengine_simulation_entry.run_recording import ( + CalculationFailedError, + ResultRecorders, + RunRecorder, +) +from policyengine_simulation_entry.simulation_database import SimulationDatabase +from policyengine_simulation_persistence import ( + RunErrorCode, + RunStatus, + SimulationKind, +) +from run_fakes import FakeInspector, FakeRunRecord, FakeRunTransitions, ManualClock + + +class OutputCapture: + """Record each household output written, by run.""" + + def __init__(self) -> None: + """Start with nothing written.""" + self.written: dict = {} + + def __call__(self, connection, run, output) -> None: + """Keep the output under its run's ID.""" + self.written[run.id] = (connection, output) + + +def household_run(run_id) -> FakeRunRecord: + """Return a running household run record.""" + return FakeRunRecord( + id=run_id, + simulation_kind=SimulationKind.HOUSEHOLD, + modal_call_id="fc-household", + check_count=0, + ) + + +def test_each_run_gets_its_own_household_with_the_calculations_notes() -> None: + capture = OutputCapture() + recorder = HouseholdResultRecorder(write_output=capture) + baseline_id, reform_id = uuid4(), uuid4() + connection = object() + + result = recorder.validate(succeeded_outcome([baseline_id, reform_id])) + recorder.write( + connection, (household_run(reform_id), household_run(baseline_id)), result + ) + + for run_id, net_income in ((baseline_id, 1000.0), (reform_id, 2000.0)): + written_connection, output = capture.written[run_id] + assert written_connection is connection + assert output.schema_version == 1 + assert output.household == calculated_household(net_income) + assert list(output.household) == ["households", "people"] + assert output.warnings == (DEPRECATED_INPUT_WARNING,) + assert output.spm_config == SPM_CONFIG + assert output.spm_provenance == spm_receipt() + assert output.bundle == BUNDLE + + +def test_a_rejected_calculation_fails_with_the_household_error_as_detail() -> None: + recorder = HouseholdResultRecorder(write_output=OutputCapture()) + + with pytest.raises(CalculationFailedError) as raised: + recorder.validate(failed_outcome()) + + error = raised.value.error + assert error.code is RunErrorCode.HOUSEHOLD_CALCULATION_FAILED + assert error.message == "The household calculation failed: invalid_inputs." + assert "employment_incme" not in error.message + assert error.detail == failed_outcome()["error"] + + +def test_an_output_that_is_no_household_outcome_is_invalid() -> None: + recorder = HouseholdResultRecorder(write_output=OutputCapture()) + + with pytest.raises(ValueError, match="validation error"): + recorder.validate({"status": "succeeded", "output": {}}) + + +def test_nothing_is_written_when_the_output_misses_one_of_the_runs() -> None: + capture = OutputCapture() + recorder = HouseholdResultRecorder(write_output=capture) + covered, missing = uuid4(), uuid4() + result = recorder.validate(succeeded_outcome([covered])) + + with pytest.raises(CalculationFailedError) as raised: + recorder.write( + object(), (household_run(covered), household_run(missing)), result + ) + + assert raised.value.error.code is RunErrorCode.INVALID_RESULT + assert capture.written == {} + + +def test_a_policy_key_names_its_run() -> None: + run_id = uuid4() + + assert policy_key_for_run(run_id) == f"run-{run_id.hex}" + + +def _recorder(capture: OutputCapture): + clock = ManualClock() + transitions = FakeRunTransitions(clock) + inspector = FakeInspector() + recorder = RunRecorder( + transitions=transitions, + runs=transitions, + inspector=inspector, + recorders=ResultRecorders( + simulations={ + SimulationKind.HOUSEHOLD: HouseholdResultRecorder(write_output=capture) + } + ), + clock=clock, + ) + return recorder, transitions, inspector + + +def test_a_reported_rejection_fails_both_runs_of_a_household_report() -> None: + recorder, transitions, inspector = _recorder(OutputCapture()) + run_ids = transitions.running_call("fc-household", "household", "household") + inspector.outcomes["fc-household"] = CallSucceeded(failed_outcome()) + + status = asyncio.run( + recorder.record_reported_call( + CallEndedReport( + modal_call_id="fc-household", simulation_run_ids=tuple(run_ids) + ) + ) + ) + + assert status is CallReportStatus.RECORDED + for run_id in run_ids: + run = transitions.runs[run_id] + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.HOUSEHOLD_CALCULATION_FAILED + assert run.error.detail == failed_outcome()["error"] + + +def test_a_reported_calculation_succeeds_both_runs_with_their_households() -> None: + capture = OutputCapture() + recorder, transitions, inspector = _recorder(capture) + run_ids = transitions.running_call("fc-household", "household", "household") + inspector.outcomes["fc-household"] = CallSucceeded(succeeded_outcome(run_ids)) + + status = asyncio.run( + recorder.record_reported_call( + CallEndedReport( + modal_call_id="fc-household", simulation_run_ids=tuple(run_ids) + ) + ) + ) + + assert status is CallReportStatus.RECORDED + assert {transitions.runs[run_id].status for run_id in run_ids} == { + RunStatus.SUCCEEDED + } + assert set(capture.written) == set(run_ids) + + +def test_the_database_recorder_records_household_calls() -> None: + database = SimulationDatabase.from_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + try: + recorders = database.call_recorder().recorders + finally: + database.dispose() + + assert isinstance( + recorders.simulations[SimulationKind.HOUSEHOLD], HouseholdResultRecorder + ) + + +def test_an_outcome_missing_one_of_the_calls_runs_fails_them_at_once() -> None: + """Such an output is invalid for these runs; waiting would never fix it.""" + capture = OutputCapture() + recorder, transitions, inspector = _recorder(capture) + run_ids = transitions.running_call("fc-household", "household", "household") + inspector.outcomes["fc-household"] = CallSucceeded(succeeded_outcome(run_ids[:1])) + + status = asyncio.run( + recorder.record_reported_call( + CallEndedReport( + modal_call_id="fc-household", simulation_run_ids=tuple(run_ids) + ) + ) + ) + + assert status is CallReportStatus.RECORDED + for run_id in run_ids: + run = transitions.runs[run_id] + assert run.status is RunStatus.FAILED + assert run.error.code is RunErrorCode.INVALID_RESULT + assert capture.written == {} diff --git a/projects/policyengine-simulation-entry/tests/test_household_report_routes.py b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py new file mode 100644 index 000000000..7bca487d6 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_report_routes.py @@ -0,0 +1,201 @@ +"""The household report routes: create a report, run it again, read it.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from conftest import FakeBackend, make_settings +from fastapi.testclient import TestClient +from household_fakes import ( + CATALOG_VERSION_ID, + DEFAULT_YEAR, + HOUSEHOLD_ID, + POLICY_ID, + POLICYENGINE_VERSION, +) +from household_harness import Harness +from household_support import calculated_household, succeeded_outcome +from policyengine_simulation_entry.app import RunServices, create_app +from policyengine_simulation_entry.modal_calls import CallSucceeded + +REPORTS_PATH = "/v2/household-reports" +REFORM = {"policy_id": str(POLICY_ID)} + + +def report_body(**overrides: object) -> dict: + """Return the JSON body of a report of current law against the stored reform.""" + body: dict[str, object] = { + "country": "us", + "policyengine_version": POLICYENGINE_VERSION, + "household_id": str(HOUSEHOLD_ID), + "reform": REFORM, + } + body.update(overrides) + return body + + +def rerun_body(**overrides: object) -> dict: + """Return the JSON body of another run of a report.""" + body: dict[str, object] = {"policyengine_version": POLICYENGINE_VERSION} + body.update(overrides) + return body + + +@pytest.fixture +def harness() -> Harness: + """Return a household calculation service over fakes.""" + return Harness() + + +@pytest.fixture +def report_client(harness: Harness): + """Return a client of an app serving the harness's household reports.""" + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + run_services=RunServices(household_calculations=harness.service), + ) + with TestClient(app) as test_client: + yield test_client + + +def test_a_created_report_is_accepted_with_its_first_run(report_client) -> None: + response = report_client.post(REPORTS_PATH, json=report_body()) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + assert body["country"] == "us" + assert body["year"] == DEFAULT_YEAR + assert body["household_id"] == str(HOUSEHOLD_ID) + assert body["trigger"] == "initial" + assert body["catalog_version_id"] == str(CATALOG_VERSION_ID) + assert body["baseline"]["policy_id"] is None + assert body["reform"]["policy_id"] == str(POLICY_ID) + assert body["baseline"]["status"] == body["reform"]["status"] == "running" + + +def test_a_report_reads_back_both_households_once_its_call_ended( + report_client, harness +) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + run_ids = ( + UUID(created["baseline"]["calculation_id"]), + UUID(created["reform"]["calculation_id"]), + ) + call_id = harness.transitions.runs[run_ids[0]].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(succeeded_outcome(run_ids)) + harness.report_call_ended(*run_ids) + + response = report_client.get(f"{REPORTS_PATH}/{created['report_id']}") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "succeeded" + assert body["report_run_id"] == created["report_run_id"] + assert body["baseline"]["result"]["household"] == calculated_household(1000.0) + assert body["reform"]["result"]["household"] == calculated_household(2000.0) + + +def test_a_report_runs_again_on_request(report_client) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + + response = report_client.post( + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(force=True) + ) + + assert response.status_code == 202 + body = response.json() + assert body["trigger"] == "manual" + assert body["report_run_id"] != created["report_run_id"] + assert body["baseline"]["attempt"] == 2 + + +@pytest.mark.parametrize( + "overrides", + [ + {"household_id": str(HOUSEHOLD_ID)}, + {"reform": REFORM}, + {"catalog_version_id": str(CATALOG_VERSION_ID)}, + ], + ids=["household", "policies", "catalog-version"], +) +def test_a_run_names_only_its_version(report_client, overrides) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + + response = report_client.post( + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(**overrides) + ) + + assert response.status_code == 422 + + +def test_an_unknown_report_is_not_found(report_client) -> None: + unknown = uuid4() + + assert report_client.get(f"{REPORTS_PATH}/{unknown}").status_code == 404 + assert ( + report_client.post( + f"{REPORTS_PATH}/{unknown}/runs", json=rerun_body() + ).status_code + == 404 + ) + + +def test_an_unknown_household_is_refused(report_client, harness) -> None: + del harness.reads.households[("us", HOUSEHOLD_ID)] + + response = report_client.post(REPORTS_PATH, json=report_body()) + + assert response.status_code == 400 + assert "has no household" in response.json()["detail"] + + +def test_without_household_calculations_the_report_routes_are_unavailable() -> None: + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + ) + with TestClient(app) as test_client: + created = test_client.post(REPORTS_PATH, json=report_body()) + rerun = test_client.post(f"{REPORTS_PATH}/{uuid4()}/runs", json=rerun_body()) + read = test_client.get(f"{REPORTS_PATH}/{uuid4()}") + + assert {created.status_code, rerun.status_code, read.status_code} == {503} + + +def test_a_report_whose_reform_value_the_contract_refuses_leaves_nothing( + report_client, harness +) -> None: + harness.reads.policies[("us", POLICY_ID)] = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": [[1]]} + } + + response = report_client.post(REPORTS_PATH, json=report_body()) + + assert response.status_code == 422 + assert harness.store.reports == {} + assert harness.store.report_runs == [] + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +def test_a_rerun_on_a_version_lacking_a_parameter_gets_422_and_writes_nothing( + report_client, harness +) -> None: + created = report_client.post(REPORTS_PATH, json=report_body()).json() + report_runs_before = list(harness.store.report_runs) + runs_before = dict(harness.store.setups) + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + + response = report_client.post( + f"{REPORTS_PATH}/{created['report_id']}/runs", json=rerun_body(force=True) + ) + + assert response.status_code == 422 + assert "gov.irs.retired" in response.json()["detail"] + assert harness.store.report_runs == report_runs_before + assert harness.store.setups == runs_before diff --git a/projects/policyengine-simulation-entry/tests/test_household_reports.py b/projects/policyengine-simulation-entry/tests/test_household_reports.py new file mode 100644 index 000000000..36fe1adb8 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_reports.py @@ -0,0 +1,274 @@ +"""Household reports: a baseline and a reform run, computed by one Modal call.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from household_fakes import ( + CATALOG_VERSION_ID, + CTC_REFORM, + DEFAULT_YEAR, + HOUSEHOLD_ID, + POLICY_ID, + POLICYENGINE_VERSION, +) +from household_harness import Harness +from household_support import calculated_household, failed_outcome, succeeded_outcome +from policyengine_simulation_entry.household_api_models import ( + HouseholdReportRunSubmission, + HouseholdReportSubmission, + household_report_status, +) +from policyengine_simulation_entry.household_calculations import ( + UnknownHouseholdReferenceError, +) +from policyengine_simulation_entry.household_recording import policy_key_for_run +from policyengine_simulation_entry.modal_calls import CallSucceeded +from policyengine_simulation_persistence import ( + PolicyParametersMissingError, + RunStatus, + RunTrigger, + UnknownCatalogVersionError, + UnknownPolicyError, +) + +if TYPE_CHECKING: + from policyengine_simulation_entry.run_dispatch import DispatchOutcome + +REFORM = {"policy_id": str(POLICY_ID)} + + +def report_submission(**overrides: object) -> HouseholdReportSubmission: + """Return a report of the stored US household: current law against the reform.""" + values: dict[str, object] = { + "country": "us", + "policyengine_version": POLICYENGINE_VERSION, + "household_id": HOUSEHOLD_ID, + "reform": REFORM, + } + values.update(overrides) + return HouseholdReportSubmission.model_validate(values) + + +def rerun_submission(**overrides: object) -> HouseholdReportRunSubmission: + """Return another run of a report on the same catalog version.""" + values: dict[str, object] = {"policyengine_version": POLICYENGINE_VERSION} + values.update(overrides) + return HouseholdReportRunSubmission.model_validate(values) + + +def submit_report(harness: Harness, **overrides: object): + """Submit a report and return it as it reads afterwards.""" + return asyncio.run(harness.service.submit_report(report_submission(**overrides))) + + +def rerun_report(harness: Harness, report_id: UUID, **overrides: object): + """Run a stored report again and return it as it reads afterwards.""" + return asyncio.run( + harness.service.rerun_report(report_id, rerun_submission(**overrides)) + ) + + +def report_runs(report) -> tuple[UUID, UUID]: + """Return a report's baseline and reform run IDs.""" + return report.baseline.run.id, report.reform.run.id + + +def test_one_call_computes_a_reports_baseline_and_reform() -> None: + harness = Harness() + + report = submit_report(harness) + + baseline_run_id, reform_run_id = report_runs(report) + assert harness.sent_request().year == str(DEFAULT_YEAR) + assert report.report.year == DEFAULT_YEAR + assert report.baseline.run.year == report.reform.run.year == DEFAULT_YEAR + assert report.report_run.reform_simulation_run_id == reform_run_id + assert report.baseline.simulation.policy_id is None + assert report.reform.simulation.policy_id == POLICY_ID + (spawned,) = harness.dispatcher.requests + assert spawned.arguments[1] == [str(baseline_run_id), str(reform_run_id)] + request = harness.sent_request() + assert request.calculation_id == report.report_run.id + assert [(policy.policy_key, policy.parameters) for policy in request.policies] == [ + (policy_key_for_run(baseline_run_id), {}), + (policy_key_for_run(reform_run_id), CTC_REFORM), + ] + assert {report.baseline.run.status, report.reform.run.status} == {RunStatus.RUNNING} + assert report.baseline.run.modal_call_id == report.reform.run.modal_call_id + + +def test_a_baseline_only_report_calculates_one_policy() -> None: + harness = Harness() + + report = submit_report(harness, reform=None) + + assert report.reform is None + assert report.report.reform_simulation_id is None + assert len(harness.sent_request().policies) == 1 + + +def test_the_shared_call_records_each_runs_household() -> None: + harness = Harness() + report = submit_report(harness) + run_ids = report_runs(report) + call_id = report.baseline.run.modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(succeeded_outcome(run_ids)) + + harness.report_call_ended(*run_ids) + + read = asyncio.run(harness.service.read_report(report.report.id)) + assert read.baseline.output.household == calculated_household(1000.0) + assert read.reform.output.household == calculated_household(2000.0) + assert household_report_status(read) is RunStatus.SUCCEEDED + + +def test_a_rejected_shared_call_fails_the_report() -> None: + harness = Harness() + report = submit_report(harness) + call_id = report.baseline.run.modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(failed_outcome()) + + harness.report_call_ended(*report_runs(report)) + + read = asyncio.run(harness.service.read_report(report.report.id)) + assert household_report_status(read) is RunStatus.FAILED + assert read.reform.run.error_detail == failed_outcome()["error"] + + +def test_a_baseline_another_calculation_computed_is_reused() -> None: + harness = Harness() + baseline = harness.submit() + call_id = harness.transitions.runs[baseline.run.id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded( + succeeded_outcome([baseline.run.id]) + ) + harness.report_call_ended(baseline.run.id) + + report = submit_report(harness) + + assert report.baseline.run.id == baseline.run.id + assert report.baseline.run.status is RunStatus.SUCCEEDED + request = harness.sent_request() + assert [policy.policy_key for policy in request.policies] == [ + policy_key_for_run(report.reform.run.id) + ] + assert household_report_status(report) is RunStatus.RUNNING + + +class ClaimingFirstStarter: + """Another submitter claims the baseline just before this one claims both.""" + + def __init__(self, harness: Harness) -> None: + """Wrap the harness's dispatch service.""" + self.dispatch = harness.service.dispatch + self.harness = harness + self.raced = False + + async def start_simulation_runs(self, run_ids, request) -> DispatchOutcome: + """Let the other submitter claim the first run once, then start these.""" + if not self.raced: + self.raced = True + self.harness.transitions.claim_simulation_dispatch([next(iter(run_ids))]) + return await self.dispatch.start_simulation_runs(run_ids, request) + + +def test_runs_another_submitter_claimed_are_left_to_it() -> None: + harness = Harness() + harness.service.dispatch = ClaimingFirstStarter(harness) + + report = submit_report(harness) + + assert report.baseline.run.status is RunStatus.DISPATCHING + assert report.reform.run.status is RunStatus.RUNNING + (spawned,) = harness.dispatcher.requests + assert spawned.arguments[1] == [str(report.reform.run.id)] + + +def test_a_report_runs_again_reusing_its_runs_unless_forced() -> None: + harness = Harness() + report = submit_report(harness) + + again = rerun_report(harness, report.report.id) + forced = rerun_report(harness, report.report.id, force=True) + + assert report_runs(again) == report_runs(report) + assert again.report_run.trigger is RunTrigger.MANUAL + assert again.report_run.id != report.report_run.id + assert forced.baseline.run.attempt == 2 + assert forced.reform.run.attempt == 2 + assert len(harness.dispatcher.requests) == 2 + latest = asyncio.run(harness.service.read_report(report.report.id)) + assert latest.report_run.id == forced.report_run.id + + +def test_a_reports_run_reads_its_own_household_and_policies_again() -> None: + harness = Harness() + report = submit_report(harness) + harness.reads.reads.clear() + + rerun_report(harness, report.report.id, force=True) + + assert harness.reads.reads == [ + "catalog_version", + "household", + "policy_parameters", + ] + request = harness.sent_request() + assert [policy.parameters for policy in request.policies] == [{}, CTC_REFORM] + + +def test_a_baseline_only_reports_run_calculates_only_its_baseline() -> None: + harness = Harness() + report = submit_report(harness, reform=None) + + again = rerun_report(harness, report.report.id, force=True) + + assert again.reform is None + assert len(harness.sent_request().policies) == 1 + + +def test_an_unknown_report_has_no_runs_to_start() -> None: + harness = Harness() + + assert rerun_report(harness, uuid4()) is None + assert asyncio.run(harness.service.read_report(uuid4())) is None + + +def test_a_catalog_version_deleted_while_submitting_leaves_no_report_behind() -> None: + harness = Harness() + harness.store.unknown_catalog_versions.add(CATALOG_VERSION_ID) + + with pytest.raises(UnknownHouseholdReferenceError): + submit_report(harness) + + assert harness.store.reports == {} + assert harness.dispatcher.requests == [] + + +@pytest.mark.parametrize( + ("change", "error"), + [ + ("unknown-reform", UnknownPolicyError), + ("missing-parameter", PolicyParametersMissingError), + ("no-catalog-version", UnknownCatalogVersionError), + ], +) +def test_a_report_sim_cannot_calculate_leaves_nothing_behind(change, error) -> None: + harness = Harness() + if change == "unknown-reform": + del harness.reads.policies[("us", POLICY_ID)] + elif change == "missing-parameter": + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + else: + del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] + + with pytest.raises(error): + submit_report(harness) + + assert harness.store.reports == {} + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] diff --git a/projects/policyengine-simulation-entry/tests/test_household_routes.py b/projects/policyengine-simulation-entry/tests/test_household_routes.py new file mode 100644 index 000000000..116ded76d --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_routes.py @@ -0,0 +1,413 @@ +"""The household calculation routes: submit, then poll until the call ends.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from conftest import FakeBackend, make_settings +from fastapi.testclient import TestClient +from household_fakes import ( + CATALOG_VERSION_ID, + DEFAULT_YEAR, + HOUSEHOLD_ID, + POLICY_ID, + POLICYENGINE_VERSION, +) +from household_harness import EARNINGS_AXES, Harness +from household_support import ( + BUNDLE, + SPM_CONFIG, + calculated_household, + failed_outcome, + spm_receipt, + succeeded_outcome, +) +from policyengine_simulation_entry.app import RunServices, create_app +from policyengine_simulation_entry.household_calculations import ( + HouseholdCalculationService, +) +from policyengine_simulation_entry.household_workers import ( + HouseholdWorkersUnavailableError, + ManifestHouseholdWorkers, +) +from policyengine_simulation_entry.modal_calls import CallSucceeded +from policyengine_simulation_entry.simulation_database import SimulationDatabase +from sqlalchemy.exc import OperationalError, ProgrammingError + +SUBMIT_PATH = "/v2/household-calculations" + + +def submission_body(**overrides: object) -> dict: + """Return the JSON body of a current-law calculation of the stored household.""" + body: dict[str, object] = { + "country": "us", + "policyengine_version": POLICYENGINE_VERSION, + "household_id": str(HOUSEHOLD_ID), + } + body.update(overrides) + return body + + +@pytest.fixture +def harness() -> Harness: + """Return a household calculation service over fakes.""" + return Harness() + + +@pytest.fixture +def household_client(harness: Harness): + """Return a client of an app serving the harness's household calculations.""" + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + run_services=RunServices(household_calculations=harness.service), + ) + with TestClient(app) as test_client: + yield test_client + + +def test_a_submitted_calculation_is_accepted_and_running(household_client) -> None: + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + assert body["household_id"] == str(HOUSEHOLD_ID) + assert body["policy_id"] is None + assert body["options"] == {} + assert body["catalog_version_id"] == str(CATALOG_VERSION_ID) + assert body["year"] == DEFAULT_YEAR + assert body["attempt"] == 1 + assert body["trigger"] == "initial" + assert body["result"] is None + assert body["error"] is None + + +def test_a_succeeded_calculation_reads_back_its_household( + household_client, harness +) -> None: + submitted = household_client.post( + SUBMIT_PATH, + json=submission_body(policy_id=str(POLICY_ID), options={"axes": EARNINGS_AXES}), + ).json() + run_id = submitted["calculation_id"] + stored_id = UUID(run_id) + call_id = harness.transitions.runs[stored_id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(succeeded_outcome([stored_id])) + harness.report_call_ended(stored_id) + + response = household_client.get(f"{SUBMIT_PATH}/{run_id}") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "succeeded" + assert body["policy_id"] == str(POLICY_ID) + assert body["options"] == {"axes": EARNINGS_AXES} + assert body["result"] == { + "household": calculated_household(1000.0), + "warnings": ["The input `age_group` is deprecated and was ignored."], + "spm_config": SPM_CONFIG, + "spm_provenance": spm_receipt(), + "bundle": BUNDLE, + } + assert list(body["result"]["household"]) == ["households", "people"] + + +def test_a_rejected_calculation_reads_back_its_household_error( + household_client, harness +) -> None: + run_id = household_client.post(SUBMIT_PATH, json=submission_body()).json()[ + "calculation_id" + ] + stored_id = UUID(run_id) + call_id = harness.transitions.runs[stored_id].modal_call_id + harness.inspector.outcomes[call_id] = CallSucceeded(failed_outcome()) + harness.report_call_ended(stored_id) + + body = household_client.get(f"{SUBMIT_PATH}/{run_id}").json() + + assert body["status"] == "failed" + assert body["result"] is None + assert body["error"] == { + "code": "household_calculation_failed", + "message": "The household calculation failed: invalid_inputs.", + "detail": failed_outcome()["error"], + } + + +def test_an_unknown_calculation_is_not_found(household_client) -> None: + response = household_client.get(f"{SUBMIT_PATH}/{uuid4()}") + + assert response.status_code == 404 + + +@pytest.mark.parametrize( + "overrides", + [ + {"household": {"people": {}}}, + {"policy": {"policy_id": str(POLICY_ID), "parameters": {}}}, + {"year": 2026}, + {"catalog_version_id": str(CATALOG_VERSION_ID)}, + {"country": "ca"}, + {"household_id": "not-an-id"}, + ], + ids=[ + "household-values", + "policy-values", + "year", + "catalog-version", + "country", + "household-id", + ], +) +def test_a_malformed_submission_is_refused_before_anything_runs( + household_client, harness, overrides +) -> None: + response = household_client.post(SUBMIT_PATH, json=submission_body(**overrides)) + + assert response.status_code == 422 + assert harness.dispatcher.requests == [] + + +def test_a_version_without_a_household_function_is_refused(household_client) -> None: + response = household_client.post( + SUBMIT_PATH, json=submission_body(policyengine_version="9.9.9") + ) + + assert response.status_code == 400 + assert "9.9.9" in response.json()["detail"] + + +def test_a_household_deleted_while_submitting_is_refused( + household_client, harness +) -> None: + harness.store.unknown_households.add(HOUSEHOLD_ID) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "The household, policy or catalog version does not exist for this country." + ) + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ("unknown-household", "has no household"), + ("unknown-policy", "has no policy"), + ("no-catalog-version", "has no catalog version"), + ("no-default-year", "default year"), + ], +) +def test_a_reference_sim_cannot_resolve_is_refused_with_why( + household_client, harness, change, reason +) -> None: + body = submission_body(policy_id=str(POLICY_ID)) + if change == "unknown-household": + body["household_id"] = "00000000-0000-4000-8000-00000000dead" + elif change == "unknown-policy": + body["policy_id"] = "00000000-0000-4000-8000-00000000dead" + elif change == "no-catalog-version": + del harness.reads.catalog_versions[("us", POLICYENGINE_VERSION)] + else: + harness.reads.store_household(None) + + response = household_client.post(SUBMIT_PATH, json=body) + + assert response.status_code == 400 + assert reason in response.json()["detail"] + assert harness.store.simulations == {} + assert harness.dispatcher.requests == [] + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ("missing-parameter", "gov.irs.retired"), + ("policy-object-value", "gov.irs.credits.ctc.amount.base[0].amount"), + ("policy-nested-list-value", "gov.irs.credits.ctc.amount.base[0].amount"), + ("group-without-members", "empty"), + ], +) +def test_caller_data_the_contract_refuses_gets_422_and_writes_nothing( + household_client, harness, change, reason +) -> None: + ctc = "gov.irs.credits.ctc.amount.base[0].amount" + if change == "missing-parameter": + harness.reads.missing_parameters[POLICY_ID] = ("gov.irs.retired",) + elif change == "policy-object-value": + harness.reads.policies[("us", POLICY_ID)] = { + ctc: {"2026-01-01.2100-12-31": {"nested": 1}} + } + elif change == "policy-nested-list-value": + harness.reads.policies[("us", POLICY_ID)] = { + ctc: {"2026-01-01.2100-12-31": [[1, 2]]} + } + else: + harness.reads.store_household( + DEFAULT_YEAR, + family=[ + {"id": "your family", "values": {}}, + {"id": "empty", "values": {}}, + ], + ) + + response = household_client.post( + SUBMIT_PATH, json=submission_body(policy_id=str(POLICY_ID)) + ) + + assert response.status_code == 422 + assert reason in response.json()["detail"] + assert harness.store.simulations == {} + assert harness.store.setups == {} + assert harness.dispatcher.requests == [] + + +def test_an_unreadable_public_api_row_asks_the_caller_to_retry( + household_client, harness +) -> None: + harness.reads.error = OperationalError("SELECT", {}, ConnectionError("down")) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert response.headers["retry-after"] + + +class UnreachableWorkers: + """Household workers whose manifest cannot be read.""" + + def resolve(self, country: str, policyengine_version: str): + """Fail as an unreachable manifest store does.""" + raise HouseholdWorkersUnavailableError(country, policyengine_version) + + +def test_an_unreadable_manifest_asks_the_caller_to_retry( + household_client, harness +) -> None: + harness.service.workers = UnreachableWorkers() + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert response.headers["retry-after"] + + +def test_without_household_calculations_the_routes_are_unavailable() -> None: + app = create_app( + settings=make_settings(), + backend=FakeBackend(), + auth_dependency=lambda: None, + ) + with TestClient(app) as test_client: + submitted = test_client.post(SUBMIT_PATH, json=submission_body()) + polled = test_client.get(f"{SUBMIT_PATH}/{uuid4()}") + + assert submitted.status_code == 503 + assert polled.status_code == 503 + + +def test_the_v2_modal_environment_is_the_call_report_environment() -> None: + settings = make_settings( + call_report_modal_workspace_id="ac-test", call_report_modal_environment="main" + ) + + assert settings.v2_modal_environment == "main" + assert make_settings().v2_modal_environment == "" + + +def test_the_simulation_database_builds_the_household_service() -> None: + database = SimulationDatabase.from_url( + "postgresql://simulation_runtime:secret@db.example:5432/postgres" + ) + try: + service = database.household_calculations("staging") + finally: + database.dispose() + + assert isinstance(service, HouseholdCalculationService) + assert isinstance(service.workers, ManifestHouseholdWorkers) + + +class InsufficientPrivilegeError(Exception): + """A psycopg error for a missing privilege (SQLSTATE 42501).""" + + sqlstate = "42501" + + +def test_missing_read_access_to_the_public_api_tables_is_named( + household_client, harness +) -> None: + harness.reads.error = ProgrammingError( + "SELECT", + {}, + InsufficientPrivilegeError("permission denied for table households"), + ) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert "lacks SELECT" in response.json()["detail"] + assert response.headers["retry-after"] + + +def test_a_long_refusal_names_at_most_ten_fields(household_client, harness) -> None: + harness.reads.policies[("us", POLICY_ID)] = { + f"gov.parameter_{index:02d}": {"2026-01-01.2100-12-31": {"nested": index}} + for index in range(15) + } + + response = household_client.post( + SUBMIT_PATH, json=submission_body(policy_id=str(POLICY_ID)) + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert "gov.parameter_09" in detail + assert "gov.parameter_10" not in detail + assert "and 5 more" in detail + + +class UndefinedTableError(Exception): + """A psycopg error for a missing table (SQLSTATE 42P01).""" + + sqlstate = "42P01" + + +def test_another_database_programming_error_asks_the_caller_to_retry( + household_client, harness +) -> None: + missing_table = "relation does not exist" + harness.reads.error = ProgrammingError( + "SELECT", {}, UndefinedTableError(missing_table) + ) + + response = household_client.post(SUBMIT_PATH, json=submission_body()) + + assert response.status_code == 503 + assert response.json()["detail"] == "The simulation database could not be reached." + + +class MissingPrivilegeStore: + """A store whose reads lack a privilege on sim's own tables.""" + + def read_calculation(self, run_id): + """Fail as a role without SELECT on the simulation schema does.""" + del run_id + statement = "SELECT" + denied = "permission denied for table simulation_runs" + raise ProgrammingError(statement, {}, InsufficientPrivilegeError(denied)) + + +def test_a_missing_privilege_on_sims_own_tables_is_not_named_the_public_apis( + household_client, harness +) -> None: + harness.service.store = MissingPrivilegeStore() + + response = household_client.get(f"{SUBMIT_PATH}/{uuid4()}") + + assert response.status_code == 503 + assert "Public API" not in response.json()["detail"] diff --git a/projects/policyengine-simulation-entry/tests/test_household_workers.py b/projects/policyengine-simulation-entry/tests/test_household_workers.py new file mode 100644 index 000000000..e88e5b5c6 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_household_workers.py @@ -0,0 +1,83 @@ +"""Finding a country's household function in the v2 version manifest.""" + +from __future__ import annotations + +import pytest +from household_fakes import POLICYENGINE_VERSION, manifest_workers +from policyengine_simulation_contract.stage12_manifest import ( + publish_v2_manifest, + v2_application_name, +) +from policyengine_simulation_entry.household_workers import ( + HouseholdFunctionUnavailableError, + HouseholdWorkersUnavailableError, + ManifestHouseholdWorkers, +) +from policyengine_simulation_entry.modal_calls import ModalTarget +from stage12_fixtures import worker + + +def test_a_countrys_household_function_and_bundle_come_from_the_manifest() -> None: + household_worker = manifest_workers().resolve("uk", POLICYENGINE_VERSION) + + assert household_worker.target == ModalTarget( + application=v2_application_name(POLICYENGINE_VERSION), + function="calculate_household_uk", + ) + assert household_worker.bundle.policyengine_version == POLICYENGINE_VERSION + assert household_worker.bundle.country_package_name == "policyengine-uk" + assert household_worker.bundle.country_package_version == "2.90.2" + assert household_worker.bundle.core_package_version == "3.30.1" + assert household_worker.bundle.bundle_manifest_sha256 == "b" * 64 + + +def test_a_version_the_manifest_lacks_has_no_household_function() -> None: + with pytest.raises(HouseholdFunctionUnavailableError, match=r"6\.0\.0"): + manifest_workers().resolve("us", "6.0.0") + + +def test_a_country_without_a_household_function_has_none() -> None: + validated = worker() + economy_only = validated.model_copy( + update={ + "countries": tuple( + country.model_copy(update={"household_callable": None}) + if country.country == "us" + else country + for country in validated.countries + ), + "validation": validated.validation.model_copy( + update={"household_validation_invocation_ids": {"uk": "household-uk"}} + ), + } + ) + store: dict[str, object] = {} + publish_v2_manifest(store=store, worker=economy_only) + + with pytest.raises(HouseholdFunctionUnavailableError, match="us"): + ManifestHouseholdWorkers.from_store(store).resolve("us", POLICYENGINE_VERSION) + + +def test_an_empty_manifest_store_means_the_workers_are_unavailable() -> None: + with pytest.raises(HouseholdWorkersUnavailableError): + ManifestHouseholdWorkers.from_store({}).resolve("us", POLICYENGINE_VERSION) + + +class UnreachableStore: + """A manifest store whose every read fails, as an unreachable Modal Dict does.""" + + def get(self, key: str, default: object = None) -> object: + """Fail the read.""" + msg = "Modal is unreachable" + raise ConnectionError(msg) + + def __setitem__(self, key: str, value: object) -> None: + """Fail the write.""" + raise ConnectionError(key) + + +def test_an_unreachable_manifest_store_means_the_workers_are_unavailable() -> None: + with pytest.raises(HouseholdWorkersUnavailableError): + ManifestHouseholdWorkers.from_store(UnreachableStore()).resolve( + "us", POLICYENGINE_VERSION + ) diff --git a/projects/policyengine-simulation-entry/tests/test_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..d19feadd0 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -27,6 +27,34 @@ class OpenAPIDocument(TypedDict): components: OpenAPIComponents +# The v2 household routes, which only the entrypoint serves. +HOUSEHOLD_ROUTES = { + ("POST", "/v2/household-calculations"): "submit_household_calculation", + ("GET", "/v2/household-calculations/{calculation_id}"): ( + "get_household_calculation" + ), + ("POST", "/v2/household-reports"): "submit_household_report", + ("POST", "/v2/household-reports/{report_id}/runs"): "run_household_report", + ("GET", "/v2/household-reports/{report_id}"): "get_household_report", +} +# The schemas only the household routes use. +HOUSEHOLD_SCHEMAS = { + "HouseholdAxis", + "HouseholdCalculationFailure", + "HouseholdCalculationOptions", + "HouseholdCalculationResponse", + "HouseholdCalculationResult", + "HouseholdCalculationSubmission", + "HouseholdPolicyChoice", + "HouseholdRefusalResponse", + "HouseholdReportResponse", + "HouseholdRequestFieldError", + "HouseholdReportRunSubmission", + "HouseholdReportSubmission", + "HouseholdRouteError", + "RunStatus", + "RunTrigger", +} EXPECTED_ROUTES = { ("GET", "/budget-window-jobs/{batch_job_id}"), ("GET", "/health"), @@ -37,6 +65,7 @@ class OpenAPIDocument(TypedDict): ("POST", "/ping"), ("POST", "/simulate/economy/budget-window"), ("POST", "/simulate/economy/comparison"), + *HOUSEHOLD_ROUTES, } @@ -62,6 +91,8 @@ def normalized_compatibility_paths(spec: OpenAPIDocument) -> OpenAPIPathMap: """Remove intentional Cloud Run and additive SPM error documentation.""" paths = deepcopy(spec["paths"]) paths.pop("/ready", None) + for _method, household_path in HOUSEHOLD_ROUTES: + paths.pop(household_path, None) for operations in paths.values(): for method, operation in operations.items(): if method.lower() in {"get", "post", "put", "patch", "delete"}: @@ -122,6 +153,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 +208,46 @@ 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_name in HOUSEHOLD_SCHEMAS: + cloud_run_schemas.pop(schema_name) assert cloud_run_schemas == gateway_spec["components"]["schemas"] gateway_operation_ids = operation_ids(gateway_spec) cloud_run_operation_ids = operation_ids(cloud_run_spec) assert { key: cloud_run_operation_ids[key] for key in gateway_operation_ids } == gateway_operation_ids + + +def test_household_routes_are_public_with_stable_operation_ids() -> None: + """The generated simulation client names its methods after these IDs.""" + spec = cast("OpenAPIDocument", create_app().openapi()) + + household_operations = { + route: operation_id + for route, operation_id in operation_ids(spec).items() + if route in HOUSEHOLD_ROUTES + } + + assert household_operations == HOUSEHOLD_ROUTES + + +def test_household_submissions_declare_their_caller_data_refusals() -> None: + """The generated client parses sim's 422 refusals as text or field errors.""" + spec = cast("OpenAPIDocument", create_app().openapi()) + + for method, path in ( + ("post", "/v2/household-calculations"), + ("post", "/v2/household-reports"), + ("post", "/v2/household-reports/{report_id}/runs"), + ): + responses = spec["paths"][path][method]["responses"] + refusal = responses["422"] + assert refusal["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/HouseholdRefusalResponse" + } + assert "parameter the catalog version lacks" in refusal["description"] + assert {"400", "503"} <= set(responses) 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..18b49b0ef --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_safety_check.py @@ -0,0 +1,144 @@ +"""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 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..01b009b51 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_safety_check_deployment.py @@ -0,0 +1,357 @@ +"""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": "OLD_GATEWAY_URL", "value": "https://legacy.example"}, + {"name": "OBSERVABILITY_SERVICE_NAMESPACE", "value": "sim"}, + {"name": "CLOUD_RUN_REGION", "value": "us-central1"}, + _secret( + "SIMULATION_DATABASE_URL", "simulation-runtime-url", "4" + ), + _secret("MODAL_TOKEN_ID", "modal-token-id", "3"), + _secret("MODAL_TOKEN_SECRET", "modal-token-secret", "3"), + _secret( + "OLD_GATEWAY_AUTH_CLIENT_SECRET", "old-client-secret", "2" + ), + ], + } + ], + }, + "status": {"imageDigest": IMAGE}, + } + + +def _fakes(tmp_path, revision, *, schedule_exists): + fakes = FakeCommands.create(tmp_path) + revision_file = tmp_path / "revision.json" + revision_file.write_text(json.dumps(revision), encoding="utf-8") + environment_capture = tmp_path / "job-environment.json" + fakes.add( + "gcloud", + f""" + case "$1 $2 $3" in + "run revisions describe") cat "{revision_file}" ;; + "run jobs deploy") + while (($#)); do + if [[ "$1" == "--env-vars-file" ]]; then + cp "$2" "{environment_capture}" + fi + shift + done + ;; + "scheduler jobs describe") {"exit 0" if schedule_exists else "exit 1"} ;; + *) ;; + esac + """, + ) + return fakes, environment_capture + + +def _deploy(tmp_path, revision=None, *, schedule_exists=False, **overrides): + fakes, environment_capture = _fakes( + tmp_path, revision or _revision(), schedule_exists=schedule_exists + ) + settings = { + "PROJECT_ID": "policyengine", + "REGION": "us-central1", + "SERVICE": SERVICE, + "SAFETY_CHECK_REVISION": REVISION, + "SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT": SCHEDULER_ACCOUNT, + "RUNNER_TEMP": str(tmp_path), + } + result = run_script(SCRIPT, env=fakes.env(**(settings | overrides))) + return result, fakes, environment_capture + + +def _call(fakes, *prefix): + calls = [ + call + for call in fakes.calls("gcloud") + if call[1 : 1 + len(prefix)] == list(prefix) + ] + assert len(calls) == 1, calls + return calls[0] + + +def _flag(call, name): + return call[call.index(name) + 1] + + +def test_the_job_runs_the_serving_revisions_exact_image_and_identity(tmp_path) -> None: + result, fakes, _ = _deploy(tmp_path) + + assert result.returncode == 0, result.stderr + deploy = _call(fakes, "run", "jobs", "deploy") + assert deploy[4] == JOB + assert _flag(deploy, "--image") == IMAGE + assert _flag(deploy, "--service-account") == RUNTIME_ACCOUNT + assert _flag(deploy, "--command") == "/opt/venv/bin/python" + assert "--args=-m,policyengine_simulation_entry.safety_check" in deploy + assert _flag(deploy, "--tasks") == "1" + assert _flag(deploy, "--max-retries") == "0" + assert _flag(deploy, "--project") == "policyengine" + assert _flag(deploy, "--region") == "us-central1" + + +def test_the_job_gets_only_the_secrets_it_reads_at_the_revisions_versions( + tmp_path, +) -> None: + result, fakes, environment_capture = _deploy(tmp_path) + + assert result.returncode == 0, result.stderr + secrets = _flag(_call(fakes, "run", "jobs", "deploy"), "--set-secrets").split(",") + assert sorted(secrets) == [ + "MODAL_TOKEN_ID=modal-token-id:3", + "MODAL_TOKEN_SECRET=modal-token-secret:3", + "SIMULATION_DATABASE_URL=simulation-runtime-url:4", + ] + environment = json.loads(environment_capture.read_text(encoding="utf-8")) + assert environment == { + "APP_ENVIRONMENT": "production", + "OBSERVABILITY_SERVICE_NAMESPACE": "sim", + "CLOUD_RUN_REGION": "us-central1", + } + + +@pytest.mark.parametrize("schedule_exists", [False, True]) +def test_the_schedule_starts_the_job_every_interval(tmp_path, schedule_exists) -> None: + result, fakes, _ = _deploy(tmp_path, schedule_exists=schedule_exists) + + assert result.returncode == 0, result.stderr + action = "update" if schedule_exists else "create" + schedule = _call(fakes, "scheduler", "jobs", action, "http") + assert schedule[5] == JOB + assert _flag(schedule, "--location") == "us-central1" + assert _flag(schedule, "--schedule") == "*/5 * * * *" + assert _flag(schedule, "--uri") == ( + "https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/" + f"namespaces/policyengine/jobs/{JOB}:run" + ) + assert _flag(schedule, "--http-method") == "POST" + assert _flag(schedule, "--oauth-service-account-email") == SCHEDULER_ACCOUNT + + +def test_the_schedule_matches_the_safety_check_interval() -> None: + assert timedelta(minutes=5) == SAFETY_CHECK_INTERVAL + + +def test_a_revision_of_another_service_is_refused(tmp_path) -> None: + result, fakes, _ = _deploy(tmp_path, _revision(service="another-service")) + + assert result.returncode != 0 + assert "another-service" in result.stderr + assert [call[1:3] for call in fakes.calls("gcloud")] == [["run", "revisions"]] + + +def test_a_revision_without_the_simulation_database_pauses_the_schedule( + tmp_path, +) -> None: + """An older revision restored by a rollback has no safety check.""" + revision = _revision( + environment=[{"name": "APP_ENVIRONMENT", "value": "production"}] + ) + + result, fakes, _ = _deploy(tmp_path, revision, schedule_exists=True) + + assert result.returncode == 0, result.stderr + assert [call[1:4] for call in fakes.calls("gcloud")] == [ + ["run", "revisions", "describe"], + ["scheduler", "jobs", "describe"], + ["scheduler", "jobs", "pause"], + ] + + +@pytest.mark.parametrize( + ("variable", "value"), + [ + ("SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT", ""), + ("SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT", "not-an-account"), + ("SAFETY_CHECK_REVISION", ""), + ("SAFETY_CHECK_REVISION", "Bad Revision"), + ("SERVICE", ""), + ], +) +def test_incomplete_settings_stop_before_any_change(tmp_path, variable, value) -> None: + result, fakes, _ = _deploy(tmp_path, **{variable: value}) + + assert result.returncode != 0 + assert variable in result.stderr + assert fakes.calls("gcloud") == [] + + +def _steps(job): + return job["steps"] + + +def _step_named(steps, name): + return next(index for index, step in enumerate(steps) if step.get("name") == name) + + +def test_promotion_points_the_job_at_the_revision_after_it_is_verified() -> None: + steps = _steps(workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)["promote_entrypoint"]) + promote = _step_named(steps, "Promote exact tested revision") + safety = _step_named(steps, "Point the safety check at the promoted revision") + restore = _step_named(steps, "Restore previous revision after failed verification") + restore_safety = _step_named( + steps, "Point the safety check back at the previous revision" + ) + + verification = [ + index + for index, step in enumerate(steps) + if index > promote and step.get("name", "").startswith("Verify") + ] + assert verification + assert max(verification) < safety < restore < restore_safety + assert SCRIPT in steps[safety]["run"] + assert steps[safety]["env"]["SAFETY_CHECK_REVISION"] == "${{ env.TARGET_REVISION }}" + assert steps[safety]["env"]["SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT"] == ( + "${{ vars.SIMULATION_SAFETY_CHECK_SCHEDULER_SERVICE_ACCOUNT }}" + ) + assert "steps.safety_check.outcome == 'failure'" in steps[restore_safety]["if"] + assert steps[restore_safety]["env"]["SAFETY_CHECK_REVISION"] == ( + "${{ env.PREVIOUS_REVISION }}" + ) + + +def test_no_candidate_deploy_touches_the_job() -> None: + jobs = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW) + for name, job in jobs.items(): + runs = " ".join(step.get("run", "") for step in job.get("steps", [])) + if SCRIPT in runs: + assert name == "promote_entrypoint" + + +def test_a_rollback_points_the_job_at_the_restored_revision() -> None: + steps = _steps(workflow_jobs(ROLLBACK_WORKFLOW)["rollback"]) + restore = _step_named(steps, "Restore entrypoint traffic") + safety = _step_named(steps, "Point the safety check at the restored revision") + + assert restore < safety + assert steps[safety]["if"] == "${{ inputs.entrypoint_revision != '' }}" + assert steps[safety]["env"]["SAFETY_CHECK_REVISION"] == ( + "${{ inputs.entrypoint_revision }}" + ) + + +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_adapter.py b/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py index 46ed669f4..7f20bc0b0 100644 --- a/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py +++ b/projects/policyengine-simulation-entry/tests/test_stage12_adapter.py @@ -188,6 +188,17 @@ def test_an_absent_breakdown_keeps_a_uk_request_eligible() -> None: assert result.report is not None +def test_adapter_skips_a_country_the_manifest_marks_household_only() -> None: + result = adapt_annual_comparison( + eligible_payload(), + evaluation_id=EVALUATION_ID, + worker=worker(household_only=("us",)), + ) + + assert result.report is None + assert result.skip_reason is ComparisonSkipReason.UNSUPPORTED_COUNTRY + + def _expected_simulation(role: str, execution_id: str, policy: dict) -> dict: dataset_uri = "hf://policyengine/data/populace_us_2024.h5@populace_us_2024-revision" return { diff --git a/projects/policyengine-simulation-entry/tests/test_stage12_backend.py b/projects/policyengine-simulation-entry/tests/test_stage12_backend.py index ec75a064b..5bb9a6b2d 100644 --- a/projects/policyengine-simulation-entry/tests/test_stage12_backend.py +++ b/projects/policyengine-simulation-entry/tests/test_stage12_backend.py @@ -29,8 +29,8 @@ class FakeLoader: - def __init__(self): - self.worker = worker() + def __init__(self, *, household_only: tuple[str, ...] = ()): + self.worker = worker(household_only=household_only) self.resolved_versions = [] def resolve(self, version=None): @@ -68,7 +68,14 @@ async def spawn(self, **call): return "modal-call-1" -def _backend(store, invoker, *, environment="staging", modal_environment="staging"): +def _backend( + store, + invoker, + *, + environment="staging", + modal_environment="staging", + household_only=(), +): runtime = MagicMock() runtime.capture_context.return_value = { "observability_id": "00000000-0000-4000-8000-000000000099", @@ -79,7 +86,7 @@ def _backend(store, invoker, *, environment="staging", modal_environment="stagin environment=environment, stage12_v2_manifest_environment=modal_environment, ), - manifest_loader=FakeLoader(), + manifest_loader=FakeLoader(household_only=household_only), store=store, invoker=invoker, runtime=runtime, @@ -347,6 +354,48 @@ def test_temporary_submission_rejects_unsupported_input_without_side_effects() - assert invoker.calls == [] +def test_temporary_submission_rejects_a_country_without_economy_reports() -> None: + store = FakeStore() + invoker = FakeInvoker() + + with pytest.raises(TemporaryStage12UnsupportedRequestError) as error: + asyncio.run( + _backend(store, invoker, household_only=("us",)).submit_temporary_report( + request_payload=eligible_payload(), + request_id="request-1", + observability_id=OBSERVABILITY_ID, + ) + ) + + assert error.value.reason == "unsupported_country" + assert store.events == [] + assert invoker.calls == [] + + +def test_automatic_dispatch_skips_a_country_without_economy_reports() -> None: + invoker = FakeInvoker() + backend = _backend(FakeStore(), invoker, household_only=("us",)) + + asyncio.run( + backend.dispatch_after_production( + request_payload=eligible_payload(), + production_response=_response(), + request_id="request-1", + observability_id=OBSERVABILITY_ID, + ) + ) + + assert invoker.calls == [] + backend._runtime.event.assert_called_once_with( + "stage12_comparison_run_skipped", + attributes={ + "request_id": "request-1", + "production_identity": "job-1", + "reason": "unsupported_country", + }, + ) + + def test_temporary_status_reads_coordinator_owned_postgres_state() -> None: store = FakeStore() invoker = FakeInvoker() diff --git a/projects/policyengine-simulation-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/tests/test_staging_household_deployment.py b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py new file mode 100644 index 000000000..633da6cfb --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_staging_household_deployment.py @@ -0,0 +1,174 @@ +"""The staging deploy calculates a household on the promoted revision. + +The household function reports a call's end to the stable URL, which serves +the promoted revision, so the staging household job runs after promotion, +only in staging, and only when the deploy also deployed the v2 application. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import httpx +import pytest +from test_deployment_assets_support import REUSABLE_DEPLOY_WORKFLOW, workflow_jobs + +JOB_NAME = "staging_household_test" +TEST_FILE = "authenticated_tests/test_staging_household_calculation.py" + + +def _job() -> dict: + return workflow_jobs(REUSABLE_DEPLOY_WORKFLOW)[JOB_NAME] + + +def test_the_job_runs_after_a_successful_staging_promotion_of_the_v2_path() -> None: + job = _job() + condition = " ".join(job["if"].split()) + + assert "promote_entrypoint" in job["needs"] + assert "needs.promote_entrypoint.result == 'success'" in condition + assert "inputs.deployment_environment == 'staging'" in condition + assert "inputs.deploy_stage12_v2" in condition + assert "!cancelled()" in condition + assert job["environment"] == "${{ inputs.release_environment }}" + + +def test_the_job_targets_the_stable_url_with_the_deployed_version() -> None: + environment = _job()["env"] + + assert environment["SIMULATION_STAGING_HOUSEHOLD_TEST"] == "1" + assert environment["SIMULATION_STAGING_TEST_BASE_URL"] == ( + "${{ needs.deploy_entrypoint.outputs.stable_url }}" + ) + assert environment["SIMULATION_STAGING_TEST_POLICYENGINE_VERSION"] == ( + "${{ needs.prepare.outputs.policyengine_version }}" + ) + assert environment["SIMULATION_STAGING_TEST_PUBLIC_API_URL"] == ( + "${{ vars.PUBLIC_API_URL }}" + ) + assert {"deploy_entrypoint", "prepare"} <= set(_job()["needs"]) + + +def test_the_job_authenticates_as_the_deployed_test_client() -> None: + environment = _job()["env"] + + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}" + ) + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}" + ) + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }}" + ) + assert environment["SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET"] == ( + "${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }}" + ) + + +def test_the_job_runs_only_the_staging_household_test() -> None: + commands = [step.get("run", "") for step in _job()["steps"]] + + assert f"uv run --frozen pytest {TEST_FILE} -v" in commands + assert all("\n" not in command.strip() for command in commands) + + +def test_other_runs_of_the_deployed_tests_skip_the_household_test() -> None: + jobs = workflow_jobs(REUSABLE_DEPLOY_WORKFLOW) + flagged = [ + name + for name, job in jobs.items() + if "SIMULATION_STAGING_HOUSEHOLD_TEST" in job.get("env", {}) + or any( + "SIMULATION_STAGING_HOUSEHOLD_TEST" in step.get("env", {}) + for step in job.get("steps", []) + ) + ] + + assert flagged == [JOB_NAME] + + +def _staging_test_module(): + """Load the staging household test module, which the deployed job runs.""" + path = Path(__file__).resolve().parents[1] / TEST_FILE + specification = importlib.util.spec_from_file_location( + "staging_household_calculation", path + ) + assert specification is not None + assert specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def test_the_submission_names_the_stored_household_by_id_only() -> None: + """Sim reads the household, its policy and the catalog version itself.""" + module = _staging_test_module() + + assert module.submission_body("household-id", "6.10.0") == { + "country": "us", + "policyengine_version": "6.10.0", + "household_id": "household-id", + "force": True, + } + + +class ScriptedAnswers: + """Answer each request with the next scripted status and body.""" + + def __init__(self, answers: list[tuple[int, dict]]) -> None: + """Keep the answers in order.""" + self.answers = list(answers) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + """Return the next answer.""" + self.requests.append(request) + status, body = self.answers.pop(0) + headers = ( + {"Retry-After": "1"} if status == httpx.codes.SERVICE_UNAVAILABLE else {} + ) + return httpx.Response(status, json=body, headers=headers) + + +def test_polling_waits_out_a_service_that_asks_to_retry(monkeypatch) -> None: + module = _staging_test_module() + answers = ScriptedAnswers( + [ + (503, {"detail": "The simulation database could not be reached."}), + (200, {"status": "running"}), + (200, {"status": "succeeded", "result": {}}), + ] + ) + waits: list[float] = [] + monkeypatch.setattr(module.time, "sleep", waits.append) + monkeypatch.setattr( + module.httpx, "get", httpx.Client(transport=httpx.MockTransport(answers)).get + ) + + finished = module.poll_until_finished( + "https://stable.example", "id", {}, deadline=module.time.monotonic() + 60 + ) + + assert finished["status"] == "succeeded" + assert len(answers.requests) == 3 + assert waits == [1.0, module.POLL_INTERVAL_SECONDS] + + +def test_a_retry_that_would_pass_the_deadline_returns_the_refusal( + monkeypatch, +) -> None: + module = _staging_test_module() + answers = ScriptedAnswers([(503, {"detail": "unavailable"})]) + monkeypatch.setattr(module.time, "sleep", pytest.fail) + + response = module.answer_within( + lambda: httpx.Client(transport=httpx.MockTransport(answers)).get( + "https://stable.example/v2/household-calculations/id" + ), + deadline=module.time.monotonic() + 0.5, + ) + + assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE + assert len(answers.requests) == 1 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/README.md b/projects/policyengine-simulation-executor/README.md index 37f5e91cb..9f6a47558 100644 --- a/projects/policyengine-simulation-executor/README.md +++ b/projects/policyengine-simulation-executor/README.md @@ -28,8 +28,12 @@ in the project dependencies and `modal-simulation-image`, then run `uv lock`. The automated updater changes only those wrapper requirements; it reads the wrapper's release manifest to verify and report the selected component and data versions. Other image dependencies belong in `modal-simulation-image`. PRs -touching image inputs run an in-image import smoke (`src/modal/smoke_app.py` via -`.github/workflows/pr-image-smoke.yml`). Note that any change to the +touching image inputs run in-image import smokes through +`.github/workflows/pr-image-smoke.yml`: `src/modal/smoke_app.py` for the v1 +worker image, and `src/modal/v2_image_smoke.py` for every v2 image (the report +coordinator, the country simulation workers, the US one without its Stage 12 +cache layer, and the household functions, where it also calculates a +household). Note that any change to the group or lock invalidates the image layer cache, including the artifact fetch layer below. diff --git a/projects/policyengine-simulation-executor/src/modal/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/utils/update_v2_version_manifest.py b/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py index a8f8801ff..70e6dfd49 100644 --- a/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py +++ b/projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py @@ -7,6 +7,9 @@ from pathlib import Path import modal +from policyengine_simulation_contract.household_execution import ( + household_callable_name, +) from policyengine_simulation_contract.stage12_manifest import ( V1_ROUTING_STATE_NAME, V2_VERSION_MANIFEST_NAME, @@ -24,8 +27,11 @@ def build_worker(validation_payload: object) -> V2WorkerVersion: """Build the installed bundle's v2 worker entry from a validation record. - Raises ValueError if the record is invalid or the entry carries a - credential-like key. + Every bundle country has a single-simulation worker, so each is recorded + as economy-capable. A country's household function is recorded when the + record shows that it calculated the validation household. Raises + ValueError if the record is invalid or the entry carries a credential-like + key. """ resolved = load_stage12_bundle() validation = V2WorkerValidation.model_validate(validation_payload) @@ -35,7 +41,13 @@ def build_worker(validation_payload: object) -> V2WorkerVersion: countries=tuple( V2CountryWorker( country=country.country, - single_simulation_callable=(f"run_single_simulation_{country.country}"), + economy_capable=True, + single_simulation_callable=f"run_single_simulation_{country.country}", + household_callable=( + household_callable_name(country.country) + if country.country in validation.household_validation_invocation_ids + else None + ), ) for country in resolved.bundle.countries ), diff --git a/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py b/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py index 198b019ef..d5fee48f6 100644 --- a/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py +++ b/projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py @@ -1,4 +1,10 @@ -"""Invoke and verify every deployed Stage 12 country validation function.""" +"""Invoke and verify every deployed v2 validation and household function. + +Each country's validation function checks its simulation worker image, and +each country's household function calculates a one-adult, current-law +household. The release record names every invocation, so the manifest names +only functions that succeeded in the deployed application. +""" from __future__ import annotations @@ -10,30 +16,40 @@ from uuid import uuid4 import modal +from policyengine_simulation_contract.household_execution import ( + HouseholdWorkerIdentity, + household_callable_name, +) from policyengine_simulation_contract.stage12_manifest import ( ManifestText, Stage12CacheRelease, V2WorkerValidation, v2_application_name, ) +from policyengine_simulation_executor.household_validation import ( + check_household_validation, + household_validation_request, +) from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle if TYPE_CHECKING: from policyengine_simulation_contract.stage12_bundle import CountryId -def _spawn_validation( +def _invoke_deployed_function( *, application_name: str, function_name: str, environment: str, + arguments: tuple[object, ...] = (), ) -> tuple[Any, str]: + """Call a deployed function, wait for its result, and return it with the call ID.""" function = modal.Function.from_name( application_name, function_name, environment_name=environment, ) - call = function.spawn() + call = function.spawn(*arguments) result = call.get(timeout=1_000) invocation_id = getattr(call, "object_id", None) if not isinstance(invocation_id, str) or not invocation_id: @@ -42,14 +58,73 @@ def _spawn_validation( return result, invocation_id +def _spawn_validation( + *, + application_name: str, + function_name: str, + environment: str, +) -> tuple[Any, str]: + return _invoke_deployed_function( + application_name=application_name, + function_name=function_name, + environment=environment, + ) + + +def _call_household_function( + *, + application_name: str, + function_name: str, + environment: str, + payload: dict[str, Any], +) -> tuple[Any, str]: + return _invoke_deployed_function( + application_name=application_name, + function_name=function_name, + environment=environment, + arguments=(payload,), + ) + + +def validate_household_functions( + *, application_name: str, environment: str, year: int +) -> dict[CountryId, ManifestText]: + """Calculate a validation household with each country's household function. + + Returns each country's invocation ID. Raises RuntimeError when a function + does not calculate the household as ``check_household_validation`` requires. + """ + invocation_ids: dict[CountryId, ManifestText] = {} + for country_bundle in load_stage12_bundle().bundle.countries: + country = country_bundle.country + function_name = household_callable_name(country) + request = household_validation_request(country, year=year) + outcome, invocation_id = _call_household_function( + application_name=application_name, + function_name=function_name, + environment=environment, + payload=request.model_dump(mode="json"), + ) + check_household_validation( + outcome, + request=request, + worker=HouseholdWorkerIdentity( + application_name=application_name, function_name=function_name + ), + ) + invocation_ids[country] = invocation_id + return invocation_ids + + def validate_release( *, environment: str, expected_cache_manifest_sha256: str ) -> V2WorkerValidation: - """Invoke each deployed country validation function and return the release record. + """Invoke each deployed validation and household function; return the record. - Raises RuntimeError when a function does not report success, returns no - invocation id, reports bundle values that differ from the local bundle, or - reports a US cache other than ``expected_cache_manifest_sha256``. + Raises RuntimeError when a validation function does not report success, + returns no invocation id, reports bundle values that differ from the local + bundle, or reports a US cache other than ``expected_cache_manifest_sha256``, + or when a household function does not calculate its validation household. """ resolved = load_stage12_bundle() application_name = v2_application_name(resolved.bundle.policyengine_version) @@ -93,6 +168,11 @@ def validate_release( msg = f"{function_name} unexpectedly reported a cache" raise RuntimeError(msg) invocation_ids[country_bundle.country] = invocation_id + household_invocation_ids = validate_household_functions( + application_name=application_name, + environment=environment, + year=datetime.now(UTC).year, + ) return V2WorkerValidation( validated=True, application_name=application_name, @@ -100,6 +180,7 @@ def validate_release( validated_at=datetime.now(UTC), validation_invocation_id=f"stage12-validation-{uuid4()}", country_validation_invocation_ids=invocation_ids, + household_validation_invocation_ids=household_invocation_ids, cache=cache_release, ) diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 92642d673..ebe10cc05 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -1,5 +1,10 @@ # pyright: reportAttributeAccessIssue=false -"""Separately named Stage 12 Modal coordinator and country workers. +"""Separately named v2 Modal application: report coordinator and country workers. + +Besides the Stage 12 report coordinator and simulation workers, it defines a +household function for each country, ``calculate_household_us`` and +``calculate_household_uk``, which calculates one household under one or more +policies in an image without datasets or secrets. This module defines additional resources. It does not rename or modify the existing ``policyengine-simulation-py{version}`` applications. @@ -8,10 +13,16 @@ from __future__ import annotations import json +import logging import os +from contextlib import nullcontext from pathlib import Path from typing import TYPE_CHECKING +from uuid import UUID +from policyengine_simulation_contract.household_execution import ( + household_callable_name, +) from policyengine_simulation_contract.stage12_manifest import v2_application_name from policyengine_simulation_executor import stage12_runtime from policyengine_simulation_executor.hf_access_validation import ( @@ -39,6 +50,7 @@ STAGE12_SEGMENT_STAGES, STAGE12_SHADOW_REPORT_STAGES, STAGE12_SIMULATION_STAGES, + V2_HOUSEHOLD_STAGES, Stage, ) from policyengine_simulation_observability.telemetry import ( @@ -53,6 +65,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from contextlib import AbstractContextManager from policyengine_simulation_contract.stage12_bundle import ( CountryId, @@ -94,6 +107,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", @@ -138,6 +168,35 @@ def _country_bundle(country: CountryId) -> Stage12CountryBundle: ) +def _v2_package_image() -> modal.Image: + """Return the base of every v2 image: the CA store and the locked packages.""" + return ( + modal.Image.debian_slim(python_version="3.13") + # Upgrade Debian's CA store before anything is downloaded, and make + # grpcio verify servers against it (src/modal/ca_certificates.py). + .apt_install(OPERATING_SYSTEM_CA_PACKAGE) + .env(GRPC_CA_BUNDLE_ENV) + .uv_sync( + uv_project_dir=_UV_PROJECT_DIR, + frozen=True, + extra_options="--only-group modal-simulation-image", + ) + ) + + +def _add_v2_sources(image: modal.Image) -> modal.Image: + """Copy the source packages and static runtime files every v2 image runs.""" + image = image.add_local_python_source( + "src.modal", + "policyengine_simulation_executor", + "policyengine_simulation_observability", + "policyengine_simulation_contract", + "policyengine_stage12_persistence", + copy=True, + ) + return add_static_runtime_files(image, uv_project_dir=_UV_PROJECT_DIR) + + def build_v2_image( countries: tuple[CountryId, ...], *, include_us_cache: bool = False ) -> modal.Image: @@ -152,16 +211,7 @@ def build_v2_image( for country in countries } image = ( - modal.Image.debian_slim(python_version="3.13") - # Upgrade Debian's CA store before anything is downloaded, and make - # grpcio verify servers against it (src/modal/ca_certificates.py). - .apt_install(OPERATING_SYSTEM_CA_PACKAGE) - .env(GRPC_CA_BUNDLE_ENV) - .uv_sync( - uv_project_dir=_UV_PROJECT_DIR, - frozen=True, - extra_options="--only-group modal-simulation-image", - ) + _v2_package_image() .run_commands( bundle_data_install_command( RESOLVED_BUNDLE.bundle.policyengine_version, @@ -199,20 +249,23 @@ def build_v2_image( # for each precomputed year), downloaded and hashed in turn. timeout=3600, ) - image = image.add_local_python_source( - "src.modal", - "policyengine_simulation_executor", - "policyengine_simulation_observability", - "policyengine_simulation_contract", - "policyengine_stage12_persistence", - copy=True, - ) - return add_static_runtime_files(image, uv_project_dir=_UV_PROJECT_DIR) + return _add_v2_sources(image) + + +def build_household_image() -> modal.Image: + """Build the household functions' image: packages and source only. + + A household calculation reads no dataset, cache, bucket or database, so + the image downloads none and its build needs no secret. The installed + PolicyEngine.py package names the bundle the functions calculate with. + """ + return _add_v2_sources(_v2_package_image().env(modal_image_environment())) us_worker_image = build_v2_image(("us",), include_us_cache=True) uk_worker_image = build_v2_image(("uk",)) coordinator_image = build_v2_image(("us", "uk")) +household_image = build_household_image() def _validate(country: CountryId) -> dict: @@ -481,3 +534,172 @@ def coordinate_report( return result finally: runtime.shutdown() + + +# Household function sizing. A call holds one country model and, for each +# policy, a simulation of the household at every axis point. Each container +# reserves 4 GiB and may use up to 16 GiB before Modal stops it, so a large +# household calculated with every variable at many axis points still fits. +# The timeout is three times the Public API's 300-second request timeout. +HOUSEHOLD_FUNCTION_CPU = 2.0 +HOUSEHOLD_FUNCTION_MEMORY_MIB = (4096, 16384) +HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS = 900 +HOUSEHOLD_FUNCTION_MAX_CONTAINERS = 20 + + +def _reporting_household_call( + simulation_run_ids: list[str] | None, +) -> AbstractContextManager[None]: + """Report the call's end to sim when it computes runs sim started. + + sim passes the runs a call computes; release validation and the image + check pass none, and nothing is reported. The report is the last step: + it starts ``report_call_ended`` once the calculation and its telemetry + have finished, whether the calculation returned or raised, but not when + Modal pre-empts the call, which Modal runs again under the same ID. + """ + if simulation_run_ids is None: + return nullcontext() + try: + run_ids = [UUID(run_id) for run_id in simulation_run_ids] + except (TypeError, ValueError): + logging.getLogger(__name__).warning( + "call_report_malformed", + extra={"modal_call_id": modal.current_function_call_id()}, + ) + return nullcontext() + from src.modal.call_reports import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + reporting_call_end, + ) + + return reporting_call_end( + modal_call_id=modal.current_function_call_id(), + simulation_run_ids=run_ids, + start_report=report_call_ended.spawn, + ) + + +def _run_household_calculation( + country: CountryId, + payload: dict, + observability_context: dict | None, +) -> dict: + """Calculate one household request for ``country`` and return its outcome. + + The outcome is a ``HouseholdExecutionResult`` or ``HouseholdExecutionFailure`` + as JSON. Raises for a malformed request or a request for another country. + """ + from policyengine_simulation_contract.household_execution import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + HouseholdExecutionRequest, + HouseholdWorkerIdentity, + ) + from policyengine_simulation_executor import ( # noqa: PLC0415 # Modal function body: loaded in the worker container, not at deploy time + household_calculation, + ) + + runtime = init_process_observability( + service_name=f"policyengine-v2-household-{country}-worker", + service_role="v2_household_worker", + platform="modal", + environment=os.getenv("MODAL_ENVIRONMENT", "local"), + ) + try: + propagated = normalize_observability_context(observability_context) + result = None + with runtime.operation( + V2_HOUSEHOLD_STAGES.name(Stage.V2_HOUSEHOLD_EXECUTION), + attributes={"country": country}, + remote_context=propagated, + ): + outcome = household_calculation.calculate_household( + HouseholdExecutionRequest.model_validate(payload), + worker=HouseholdWorkerIdentity( + application_name=APP_NAME, + function_name=household_callable_name(country), + ), + required_country=country, + dependencies=household_calculation.HouseholdCalculationDependencies( + runtime=runtime + ), + ) + result = outcome.model_dump(mode="json") + if result is None: + msg = f"the {country} household calculation returned no result" + raise RuntimeError(msg) + return result + finally: + runtime.shutdown() + + +@app.function( + image=household_image, + cpu=HOUSEHOLD_FUNCTION_CPU, + memory=HOUSEHOLD_FUNCTION_MEMORY_MIB, + timeout=HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS, + retries=0, + max_containers=HOUSEHOLD_FUNCTION_MAX_CONTAINERS, + secrets=[], +) +def calculate_household_us( + payload: dict, + simulation_run_ids: list[str] | None = None, + *, + observability_context: dict | None = None, +) -> dict: + """Calculate one US household under each requested policy. + + ``simulation_run_ids`` names the runs sim started this call for; the + call's last step reports their end to sim. + """ + with _reporting_household_call(simulation_run_ids): + return _run_household_calculation("us", payload, observability_context) + + +@app.function( + image=household_image, + cpu=HOUSEHOLD_FUNCTION_CPU, + memory=HOUSEHOLD_FUNCTION_MEMORY_MIB, + timeout=HOUSEHOLD_FUNCTION_TIMEOUT_SECONDS, + retries=0, + max_containers=HOUSEHOLD_FUNCTION_MAX_CONTAINERS, + secrets=[], +) +def calculate_household_uk( + payload: dict, + simulation_run_ids: list[str] | None = None, + *, + observability_context: dict | None = None, +) -> dict: + """Calculate one UK household under each requested policy. + + ``simulation_run_ids`` names the runs sim started this call for; the + call's last step reports their end to sim. + """ + with _reporting_household_call(simulation_run_ids): + return _run_household_calculation("uk", payload, observability_context) + + +@app.function( + 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/modal/v2_image_smoke.py b/projects/policyengine-simulation-executor/src/modal/v2_image_smoke.py new file mode 100644 index 000000000..89ef78504 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/modal/v2_image_smoke.py @@ -0,0 +1,152 @@ +# pyright: reportAttributeAccessIssue=false, reportFunctionMemberAccess=false +"""Pre-merge image smoke: build every v2 Modal image and import its entry points. + +The coordinator, UK worker and household images are the ones the v2 +application deploys. The US worker image is built without its last layer, the +Stage 12 cache: only a deployment can add that layer, because the deployment's +precompute writes the cache and the image build reads it with the deployment's +Cloud Storage credential. The layer adds data files, not Python packages. + +In each image the smoke imports the v2 application module, as Modal does +before it runs any of the application's functions, and the modules those +functions import while handling a request. In the household image it also +calculates the release validation household for each country, which shows the +image calculates households without datasets. + +Usage: + uv run modal run --env=staging src/modal/v2_image_smoke.py +""" + +from src.modal.v2_app import ( + build_v2_image, + coordinator_image, + household_image, + uk_worker_image, +) + +import modal + +SMOKE_APP_NAME = "policyengine-simulation-v2-image-smoke" +app = modal.App(SMOKE_APP_NAME) + +us_worker_image_without_cache = build_v2_image(("us",)) + +# Modules the report coordinator, simulation workers and validation functions +# import when they start or while they handle a request. +REPORT_ENTRY_POINT_MODULES = ( + "src.modal.v2_app", + "policyengine_simulation_executor.stage12_runtime", + "policyengine_simulation_executor.stage12_worker_validation", + "policyengine_simulation_executor.simulation_runtime", + "policyengine_simulation_executor.spm", + "policyengine_simulation_executor.uk_local_authority_metadata", + "microdf", + "policyengine.tax_benefit_models.us.datasets", + "policyengine.tax_benefit_models.uk.datasets", +) +# Modules the household functions import when they start or calculate. +HOUSEHOLD_ENTRY_POINT_MODULES = ( + "src.modal.v2_app", + "policyengine_simulation_executor.household_calculation", + "policyengine_simulation_executor.household_validation", +) + + +def _import_modules(names: tuple[str, ...]) -> list[str]: + """Import each named module, raising on the first that fails.""" + import importlib # noqa: PLC0415 # smoke app: standard-library import kept with the in-image check that uses it + + for name in names: + importlib.import_module(name) + return list(names) + + +def _report_image_smoke() -> dict[str, object]: + """Import the report entry points; return them and the installed bundle version.""" + from policyengine_simulation_executor.stage12_bundle import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + load_stage12_bundle, + ) + + return { + "modules": _import_modules(REPORT_ENTRY_POINT_MODULES), + "policyengine_version": load_stage12_bundle().bundle.policyengine_version, + } + + +@app.function(image=coordinator_image, timeout=600, memory=8192) +def smoke_v2_coordinator_image() -> dict[str, object]: + """Import the v2 entry points inside the report coordinator's image.""" + return _report_image_smoke() + + +@app.function(image=uk_worker_image, timeout=600, memory=8192) +def smoke_v2_uk_worker_image() -> dict[str, object]: + """Import the v2 entry points inside the UK simulation worker's image.""" + return _report_image_smoke() + + +@app.function(image=us_worker_image_without_cache, timeout=600, memory=8192) +def smoke_v2_us_worker_image() -> dict[str, object]: + """Import the v2 entry points inside the US simulation worker's image. + + The image has every layer of the deployed image except the Stage 12 cache. + """ + return _report_image_smoke() + + +@app.function(image=household_image, timeout=900, memory=(4096, 16384)) +def smoke_v2_household_image() -> dict[str, object]: + """Import the household entry points and calculate each validation household. + + Raises when a calculation fails or does not return a household net income. + """ + from datetime import ( # noqa: PLC0415 # smoke app: standard-library import kept with the in-image check that uses it + UTC, + datetime, + ) + + from policyengine_simulation_contract.household_execution import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + HouseholdWorkerIdentity, + household_callable_name, + ) + from policyengine_simulation_executor import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + household_calculation, + ) + from policyengine_simulation_executor.household_validation import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + check_household_validation, + household_validation_request, + ) + from policyengine_simulation_executor.stage12_bundle import ( # noqa: PLC0415 # smoke check: imported inside the Modal image, which is what this verifies + load_stage12_bundle, + ) + + modules = _import_modules(HOUSEHOLD_ENTRY_POINT_MODULES) + year = datetime.now(UTC).year + net_incomes: dict[str, float] = {} + for country_bundle in load_stage12_bundle().bundle.countries: + country = country_bundle.country + request = household_validation_request(country, year=year) + worker = HouseholdWorkerIdentity( + application_name=SMOKE_APP_NAME, + function_name=household_callable_name(country), + ) + outcome = household_calculation.calculate_household( + request, worker=worker, required_country=country + ) + net_incomes[country] = check_household_validation( + outcome.model_dump(mode="json"), request=request, worker=worker + ) + return {"modules": modules, "household_net_income": net_incomes} + + +@app.local_entrypoint() +def main() -> None: + """Run every v2 image smoke remotely and print their reports.""" + reports = { + "coordinator": smoke_v2_coordinator_image.remote(), + "uk_worker": smoke_v2_uk_worker_image.remote(), + "us_worker": smoke_v2_us_worker_image.remote(), + "household": smoke_v2_household_image.remote(), + } + print(reports) # noqa: T201 # Modal local entrypoint: runs locally, and stdout is the smoke's report + print("v2 image smoke OK") # noqa: T201 # Modal local entrypoint: runs locally, and stdout is the smoke's report diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py new file mode 100644 index 000000000..40615f7dc --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_calculation.py @@ -0,0 +1,689 @@ +"""Household calculations for the v2 Modal household functions. + +``calculate_household`` runs one ``HouseholdExecutionRequest``: it calculates +the household under each requested policy, in order, and returns a +``HouseholdExecutionResult`` or a typed ``HouseholdExecutionFailure``. + +The calculation reproduces the Public API's in-process household calculation +(its household calculation service, household routes and country module): the +same input preparation, simulation construction, reform value conversion, +requested values, value formatting, handling of values that cannot be +calculated, warnings and SPM receipt. Failures carry the codes and messages of +the Public API's matching error responses. This keeps v2 results comparable +with the Public API's results while both exist. + +Every known difference from the Public API is listed here: + +- An undefined number is the string "NaN": the contract carries only finite + numbers and JSON scalars, and the Public API writes a bare ``NaN``, which + is not JSON. A date is the HTTP date text the Public API's Flask encoder + writes. +- Missing variables are added for the request's ``year``. The Public API + adds them for the first period of the age of a person named ``you``, or + else its host's current year (and fails with an internal error when that + age is one value, not by period); v2 households name people by record ID. +- A variable given as one value, not by period, is that value for ``year``, + and an axis without a period varies ``year``; the calculated household + shows both with that period. The Public API leaves them to the country + model's own default year (currently 2024 for the US model and 2025 for the + UK model), and does not calculate a variable given as one null value. +- ``year`` sets only those periods. Every other value is calculated for the + period the household names, which the function does not compare with + ``year``, as the Public API does not. +- An explicit null ``axes`` means no axes, because the contract drops it; the + Public API treats the key's presence as a household with axes. +- Every policy's parameters are checked before any policy is calculated, so + an unknown parameter in a reform fails the whole request. Likewise any + failure under one policy, such as a reform value that cannot be converted + or an SPM error only the reform causes, returns one failure for the whole + request, without the policies already calculated; the Public API's + separate request for each policy would still return the others. +- Its messages are the request routes' (``/calculate`` and + ``/calculate-full``). The stored-household route words a household it + cannot read, and any other failure except an SPM error, with the household + and policy IDs, which a household function does not have. +- A request the contract's models reject, such as a group without + ``members``, never reaches this module; the caller reports it. +""" + +from __future__ import annotations + +import importlib +import json +import logging +import math +from contextlib import AbstractContextManager, nullcontext +from dataclasses import dataclass, field +from datetime import UTC, date, datetime, time +from email.utils import format_datetime +from functools import lru_cache +from typing import TYPE_CHECKING, Any + +from policyengine_core.enums import Enum +from policyengine_core.errors import SituationParsingError +from policyengine_core.parameters import get_parameter +from policyengine_core.periods import period as parse_period +from policyengine_simulation_contract.household_execution import ( + CalculatedHousehold, + HouseholdBundleProvenance, + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdExecutionFailure, + HouseholdExecutionResult, + HouseholdPolicyResult, +) +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMInputError, + SPMResolvedConfiguration, +) +from policyengine_simulation_observability.stages import V2_HOUSEHOLD_STAGES, Stage + +from policyengine_simulation_executor.household_inputs import ( + HouseholdModelMetadata, + RequestedValue, + add_missing_variables, + assign_request_year, + drop_deprecated_inputs, + find_unrecognized_household_inputs, + find_unrecognized_policy_inputs, + requested_computations, + unrecognized_inputs_message, +) +from policyengine_simulation_executor.household_spm import ( + HOUSEHOLD_SPM_INPUT_ERROR_CODES, + household_spm_error_detail, + household_spm_provenance, + resolve_household_spm_selection, +) +from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from policyengine_core.periods import Period + from policyengine_core.simulations import Simulation + from policyengine_core.taxbenefitsystems import TaxBenefitSystem + from policyengine_observability import ObservabilityRuntime + from policyengine_simulation_contract.household_execution import ( + HouseholdErrorCode, + HouseholdExecutionRequest, + HouseholdPolicy, + HouseholdWorkerIdentity, + ) + from policyengine_simulation_contract.spm import SPMProvenance + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CountryHouseholdModel: + """A loaded country model: its ``Simulation`` type, tax-benefit system and metadata. + + The tax-benefit system supplies the metadata and the value types of + reform parameters; simulations build their own, as in the Public API. + """ + + country: str + simulation_type: Callable[..., Simulation] + tax_benefit_system: TaxBenefitSystem + metadata: HouseholdModelMetadata + + +@lru_cache(maxsize=4) +def load_country_household_model(country: str) -> CountryHouseholdModel: + """Import ``country``'s model package and build its tax-benefit system once. + + The package is the one the installed PolicyEngine.py bundle names for the + country. The result is kept for the life of the process, so warm Modal + containers reuse it. Raises ValueError for a country the bundle lacks. + """ + bundle = load_stage12_bundle().bundle + country_bundle = next( + (item for item in bundle.countries if item.country == country), None + ) + if country_bundle is None: + msg = f"the installed bundle does not carry country {country!r}" + raise ValueError(msg) + package = importlib.import_module( + country_bundle.country_package_name.replace("-", "_") + ) + system = package.CountryTaxBenefitSystem() + return CountryHouseholdModel( + country=country, + simulation_type=package.Simulation, + tax_benefit_system=system, + metadata=HouseholdModelMetadata.from_tax_benefit_system(system), + ) + + +def installed_household_bundle(country: str) -> HouseholdBundleProvenance: + """Return the provenance of the installed bundle for ``country``.""" + resolved = load_stage12_bundle() + return HouseholdBundleProvenance.from_bundle( + resolved.bundle, + bundle_manifest_sha256=resolved.bundle_manifest_sha256, + country=country, + ) + + +@dataclass(frozen=True) +class HouseholdCalculationDependencies: + """Seams a household calculation uses; unset seams use the defaults. + + The defaults are the installed bundle, the process's cached country + models and no telemetry runtime. + """ + + installed_bundle: HouseholdBundleProvenance | None = None + model_loader: Callable[[str], CountryHouseholdModel] = load_country_household_model + runtime: ObservabilityRuntime | None = None + + +class _CalculationFailedError(Exception): + """A household calculation ended with a typed error.""" + + def __init__(self, error: HouseholdCalculationError) -> None: + super().__init__(error.message) + self.error = error + + +def _failure( + code: HouseholdErrorCode, + message: str, + *, + policy_key: str | None = None, +) -> _CalculationFailedError: + return _CalculationFailedError( + HouseholdCalculationError(code=code, message=message, policy_key=policy_key) + ) + + +def _spm_failure( + error: BaseException, *, policy_key: str | None +) -> _CalculationFailedError | None: + detail = household_spm_error_detail(error) + if detail is None: + return None + return _CalculationFailedError( + HouseholdCalculationError( + code="invalid_spm_selection", + message=detail.message, + spm_error=detail, + policy_key=policy_key, + ) + ) + + +def _invalid_household_failure( + error: SituationParsingError, *, policy_key: str +) -> _CalculationFailedError: + """Report a household the country model cannot read, as the Public API does.""" + return _failure( + "invalid_household", + f"Invalid household payload: {error}", + policy_key=policy_key, + ) + + +def _bundle_mismatch_failure( + bundle: HouseholdBundleProvenance, +) -> _CalculationFailedError: + """Report a request for a bundle other than ``bundle``, the one installed.""" + return _failure( + "bundle_mismatch", + "This household function runs PolicyEngine.py " + f"{bundle.policyengine_version} with {bundle.country_package_name} " + f"{bundle.country_package_version} " + f"(bundle {bundle.bundle_manifest_sha256}), not the requested bundle", + ) + + +def _unexpected_failure( + error: BaseException, *, policy_key: str +) -> _CalculationFailedError: + """Report an SPM error as such, and anything else as a failed calculation.""" + failure = _spm_failure(error, policy_key=policy_key) + if failure is not None: + return failure + logger.error( + "Error calculating household under policy %s", policy_key, exc_info=error + ) + return _failure( + "calculation_failed", + f"Error calculating household under policy: {error}", + policy_key=policy_key, + ) + + +def _stage_scope( + runtime: ObservabilityRuntime | None, + stage: Stage, + attributes: Mapping[str, object], +) -> AbstractContextManager[object]: + if runtime is None: + return nullcontext() + return runtime.span(V2_HOUSEHOLD_STAGES.name(stage), attributes=dict(attributes)) + + +# Value formatting, as the Public API's country module and JSON encoder +# write values. + + +def _serialize_float(value: object) -> float | str: + """Write a number as the Public API does, naming non-finite values.""" + serialized = float(str(value)) + if math.isinf(serialized): + return "Infinity" if serialized > 0 else "-Infinity" + if math.isnan(serialized): + return "NaN" + return serialized + + +def _serialize_scalar(value: object) -> object: + """Write a date as the HTTP date text the Public API's Flask encoder writes.""" + if isinstance(value, datetime): + moment = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return format_datetime(moment.astimezone(UTC), usegmt=True) + if isinstance(value, date): + return format_datetime(datetime.combine(value, time(), tzinfo=UTC), usegmt=True) + return value + + +def _serialize_axis_values( + result: Any, # noqa: ANN401 # a NumPy array or EnumArray from the country model + value_type: type, + entity_index: int, + entity_count: int, +) -> list[object]: + if value_type == Enum: + result = result.decode_to_str() + entity_values: list[object] = ( + result.reshape((-1, entity_count)).T[entity_index].tolist() + ) + if value_type is float: + return [_serialize_float(value) for value in entity_values] + if value_type is str: + return [str(value) for value in entity_values] + return [_serialize_scalar(value) for value in entity_values] + + +def _serialize_entity_value( + result: Any, # noqa: ANN401 # a NumPy array or EnumArray from the country model + value_type: type, + entity_index: int, +) -> object: + if value_type == Enum: + return result.decode()[entity_index].name + if value_type is float: + return _serialize_float(result[entity_index]) + if value_type is str: + return str(result[entity_index]) + return _serialize_scalar(result.tolist()[entity_index]) + + +@dataclass(frozen=True) +class _PreparedHousehold: + """A household ready to calculate under each policy. + + ``situation`` has its values and axes without a period put in the + request's year, its missing variables added for that year and its + deprecated inputs removed; ``input_warnings`` say which inputs were + removed. + ``spm_selection`` is the resolved SPM selection (None when no SPM + applies) and ``spm_requested`` whether the caller chose it. + """ + + situation: dict[str, Any] + input_warnings: tuple[str, ...] + spm_selection: dict[str, Any] | None + spm_requested: bool + + +def _normalized_reform( + reform: Mapping[str, Mapping[str, object]], + system: TaxBenefitSystem, +) -> dict[str, dict[str, object]]: + """Convert reform values to each parameter's current value type. + + Integers become floats, and numeric text becomes a number first, as in the + Public API. + """ + root = system.parameters + if root is None: + msg = "the country model has no parameters" + raise LookupError(msg) + normalized: dict[str, dict[str, object]] = {} + for parameter_name, parameter_updates in reform.items(): + parameter = get_parameter(root, parameter_name) + convert = _reform_value_type(parameter.values_list[-1].value) + normalized[parameter_name] = { + time_period: convert(_numeric_if_possible(value)) + for time_period, value in parameter_updates.items() + } + return normalized + + +def _reform_value_type(current_value: object) -> Callable[[Any], object]: + """Return the type a reform value becomes: the current value's, int as float.""" + value_type = type(current_value) + return float if value_type is int else value_type + + +def _numeric_if_possible(value: object) -> object: + """Return ``value`` as a float when it reads as one, as the Public API does.""" + if not isinstance(value, (bool, int, float, str)): + return value + try: + return float(value) + except (ValueError, OverflowError): + return value + + +def _create_simulation( + model: CountryHouseholdModel, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> Simulation: + """Build the country model's simulation of the household under ``policy``. + + The country ``Simulation`` takes the reform as converted parameter values + and the resolved SPM selection, as the Public API passes them. + """ + options: dict[str, object] = { + "situation": json.loads(json.dumps(prepared.situation)) + } + if prepared.spm_selection is not None: + options["spm"] = dict(prepared.spm_selection) + if policy.parameters: + options["reform"] = _normalized_reform( + policy.parameters, model.tax_benefit_system + ) + return model.simulation_type(**options) + + +def _model_period(text: str) -> Period: + """Read a household period key as policyengine-core's ``calculate`` reads it. + + Raises ValueError for text that names no period. + """ + parsed = parse_period(text) + if parsed is None: + msg = f"{text!r} names no period" + raise ValueError(msg) + return parsed + + +@dataclass +class _PolicyCalculation: + """One policy's simulation and the household its values are written into.""" + + simulation: Simulation + household: dict[str, Any] + spm_requested: bool + warnings: list[str] = field(default_factory=list) + + @property + def has_axes(self) -> bool: + """Whether the household is calculated at several axis points.""" + return "axes" in self.household + + def calculate_all(self) -> None: + """Calculate every requested value, as the Public API's country module does.""" + system = self.simulation.tax_benefit_system + computations = requested_computations( + self.household, + include_provided_values=self.has_axes, + variable_names=set(system.variables) if self.has_axes else None, + ) + for requested in computations: + try: + self._calculate_value(requested) + except Exception as error: + detail = household_spm_error_detail(error) + # A chosen measurement reports its missing inputs. An + # inherited one leaves its dependants uncalculated, but only + # for a missing input: a configuration failure always ends + # the calculation. + if detail is not None and ( + self.spm_requested + or detail.code not in HOUSEHOLD_SPM_INPUT_ERROR_CODES + ): + raise + self._record_failure(requested, error) + + def _calculate_value(self, requested: RequestedValue) -> None: + entity_plural, entity_id, variable_name, period = requested + population = self.simulation.get_population(entity_plural) + variable = self.simulation.tax_benefit_system.get_variable(variable_name) + if variable is None: + msg = f"the country model has no variable {variable_name}" + raise LookupError(msg) + value_type = variable.value_type + result = self.simulation.calculate(variable_name, _model_period(period)) + entities = self.household[entity_plural] + if self.has_axes: + value: object = _serialize_axis_values( + result, value_type, list(entities).index(entity_id), len(entities) + ) + else: + value = _serialize_entity_value( + result, value_type, population.get_index(entity_id) + ) + entities[entity_id][variable_name][period] = value + + def _record_failure(self, requested: RequestedValue, error: Exception) -> None: + entity_plural, entity_id, variable_name, period = requested + cells = self.household[entity_plural][entity_id][variable_name] + if not self.has_axes: + cells[period] = None + logger.warning( + "Unable to calculate %s for %s in %s: %s", + variable_name, + entity_id, + period, + error, + ) + return + population = self.simulation.get_population(entity_plural) + axis_point_count = population.count // len(self.household[entity_plural]) + cells[period] = [None] * axis_point_count + warning = ( + f"Unable to calculate {variable_name} for {entity_id} in " + f"{period}; returned {axis_point_count} null axis values: {error}" + ) + self.warnings.append(warning) + logger.warning("%s", warning) + + +def _parsed_policy_calculation( + model: CountryHouseholdModel, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> _PolicyCalculation: + """Build the policy's simulation, failing as the Public API's parse stage does.""" + try: + simulation = _create_simulation(model, prepared, policy) + except SituationParsingError as error: + raise _invalid_household_failure(error, policy_key=policy.policy_key) from error + except Exception as error: + raise _unexpected_failure(error, policy_key=policy.policy_key) from error + return _PolicyCalculation( + simulation=simulation, + household=json.loads(json.dumps(prepared.situation)), + spm_requested=prepared.spm_requested, + ) + + +def _calculated_policy_result( + calculation: _PolicyCalculation, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> HouseholdPolicyResult: + """Calculate the requested values and return the policy's result.""" + calculation.calculate_all() + spm_provenance: SPMProvenance | None = household_spm_provenance( + calculation.simulation, prepared.spm_selection + ) + return HouseholdPolicyResult( + policy_key=policy.policy_key, + household=CalculatedHousehold.model_validate(calculation.household), + warnings=(*prepared.input_warnings, *calculation.warnings), + spm_provenance=spm_provenance, + ) + + +def _calculate_policy( + model: CountryHouseholdModel, + prepared: _PreparedHousehold, + policy: HouseholdPolicy, +) -> HouseholdPolicyResult: + """Calculate the household under one policy, raising a typed failure.""" + calculation = _parsed_policy_calculation(model, prepared, policy) + try: + return _calculated_policy_result(calculation, prepared, policy) + except SituationParsingError as error: + failure = _spm_failure( + error, policy_key=policy.policy_key + ) or _invalid_household_failure(error, policy_key=policy.policy_key) + raise failure from error + except Exception as error: + raise _unexpected_failure(error, policy_key=policy.policy_key) from error + + +def _resolved_spm_selection( + request: HouseholdExecutionRequest, +) -> dict[str, Any] | None: + try: + return resolve_household_spm_selection( + request.country, + request.spm, + sent=request.spm_requested, + saved_household=request.spm_selection_source == "saved_household", + ) + except SPMInputError as error: + raise _CalculationFailedError( + HouseholdCalculationError( + code="invalid_spm_selection", + message=error.message, + spm_error=SPMErrorDetail(code=error.code, message=error.message), + ) + ) from error + + +def _prepared_household( + request: HouseholdExecutionRequest, + model: CountryHouseholdModel, + *, + spm_selection: dict[str, Any] | None, +) -> _PreparedHousehold: + """Prepare the household as the Public API does; raise for unknown names. + + Unknown household names are reported with the first policy, as the Public + API's baseline request would report them; unknown policy parameters are + reported with the policy that names them. + """ + situation = request.household.to_situation() + assign_request_year(situation, year=request.year) + if request.add_missing_variables: + add_missing_variables(situation, model.metadata, year=request.year) + deprecated = drop_deprecated_inputs(situation) + household_errors = find_unrecognized_household_inputs( + deprecated.household, model.metadata + ) + for policy in request.policies: + policy_errors = find_unrecognized_policy_inputs( + policy.parameters, model.metadata + ) + input_errors = (*household_errors, *policy_errors) + if input_errors: + raise _CalculationFailedError( + HouseholdCalculationError( + code="invalid_inputs", + message=unrecognized_inputs_message(input_errors), + input_errors=input_errors, + policy_key=policy.policy_key if policy_errors else None, + ) + ) + return _PreparedHousehold( + situation=deprecated.household, + input_warnings=tuple(warning.message for warning in deprecated.warnings), + spm_selection=spm_selection, + spm_requested=request.spm_requested, + ) + + +def _calculate_output( + request: HouseholdExecutionRequest, + bundle: HouseholdBundleProvenance, + dependencies: HouseholdCalculationDependencies, +) -> HouseholdCalculationOutput: + """Calculate every policy of ``request``, raising a typed failure. + + After the bundle check and loading the country model, checks run in the + Public API's order: the SPM selection, the household's and policies' + names, then each policy. The model loads first so its span measures the + whole load, which for the US otherwise happens while the SPM selection is + resolved. + """ + if request.bundle != bundle: + raise _bundle_mismatch_failure(bundle) + country = {"country": request.country} + runtime = dependencies.runtime + with _stage_scope(runtime, Stage.V2_HOUSEHOLD_COUNTRY_MODEL_LOAD, country): + model = dependencies.model_loader(request.country) + with _stage_scope(runtime, Stage.V2_HOUSEHOLD_INPUT_NORMALIZATION, country): + spm_selection = _resolved_spm_selection(request) + prepared = _prepared_household(request, model, spm_selection=spm_selection) + results = [] + for policy in request.policies: + attributes = {**country, "policy_key": policy.policy_key} + with _stage_scope(runtime, Stage.V2_HOUSEHOLD_CALCULATION, attributes): + results.append(_calculate_policy(model, prepared, policy)) + return HouseholdCalculationOutput( + country=request.country, + bundle=bundle, + spm_config=( + SPMResolvedConfiguration.model_validate(spm_selection) + if spm_selection is not None + else None + ), + results=tuple(results), + ) + + +def calculate_household( + request: HouseholdExecutionRequest, + *, + worker: HouseholdWorkerIdentity, + required_country: str | None = None, + dependencies: HouseholdCalculationDependencies | None = None, +) -> HouseholdExecutionResult | HouseholdExecutionFailure: + """Calculate one household under each requested policy. + + Returns the per-policy results, or a typed failure for a request for + another bundle, an unusable SPM selection, unrecognized inputs, a household + the country model cannot build, or any other calculation error. Raises + ValueError when ``required_country`` is set and the request names another + country; a household function calculates its own country only. + """ + if required_country is not None and request.country != required_country: + msg = f"this function calculates {required_country} households only" + raise ValueError(msg) + dependencies = dependencies or HouseholdCalculationDependencies() + bundle = dependencies.installed_bundle or installed_household_bundle( + request.country + ) + try: + output = _calculate_output(request, bundle, dependencies) + except _CalculationFailedError as failure: + return HouseholdExecutionFailure( + calculation_id=request.calculation_id, + error=failure.error, + worker=worker, + ) + return HouseholdExecutionResult( + calculation_id=request.calculation_id, + output=output, + worker=worker, + ) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py new file mode 100644 index 000000000..67de12a67 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_inputs.py @@ -0,0 +1,501 @@ +"""Household input preparation, matching the Public API's in-process calculation. + +These functions reproduce, for the v2 household functions, how the Public API +prepares a household before calculating it: which missing variables are added, +which deprecated inputs are dropped (and the warning that says so), which +names are reported as unrecognized, and which values are requested. Keeping +the wording and order identical lets a v2 result be compared with the Public +API's result field by field. + +Two steps differ, because a v2 request names its year: missing variables are +added for that year, where the Public API infers one, and +``assign_request_year`` puts values and axes the household gives without a +period in that year, where the Public API leaves them to the country model's +own default year. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from policyengine_simulation_contract.household_execution import HouseholdInputError + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from policyengine_core.taxbenefitsystems import TaxBenefitSystem + from policyengine_core.variables import Variable + from policyengine_simulation_contract.household_execution import InputErrorType + +# Removed or renamed model variables that callers may still send. The value is +# the migration hint shown in the warning. Matches the Public API's registry. +DEPRECATED_VARIABLES: dict[str, str] = { + "medical_out_of_pocket_expenses": ( + "Removed in policyengine-us 1.673.0. Migrate non-premium spending " + "to `other_medical_expenses` and premium spending to " + "`health_insurance_premiums`." + ), + "uc_has_limited_capability_for_work": ( + "Removed in policyengine-uk 2.129.0. Set " + "`uc_limited_capability_for_work` instead." + ), +} +# Variables with these definition periods are added when missing. +_ADDED_DEFINITION_PERIODS = frozenset({"year", "month", "eternity"}) +_AXES_KEY = "axes" + +# One value to calculate: entity plural, entity name, variable and period. +RequestedValue = tuple[str, str, str, str] + + +@dataclass(frozen=True) +class HouseholdVariable: + """One country-model variable, as household preparation needs it. + + ``default_value`` is the value the Public API's metadata reports: the + enum member name for an enum variable, the default for a boolean or + numeric variable, and None otherwise. + """ + + name: str + entity: str + definition_period: str + is_input: bool + default_value: bool | int | float | str | None + + +@dataclass(frozen=True) +class HouseholdEntity: + """One country-model entity: its key, plural and the plurals of its roles.""" + + key: str + plural: str + role_plurals: frozenset[str] + + +@dataclass(frozen=True) +class HouseholdModelMetadata: + """The variables, entities and policy parameters of one country model.""" + + variables: Mapping[str, HouseholdVariable] + entities: Mapping[str, HouseholdEntity] + parameters: frozenset[str] + + @classmethod + def from_tax_benefit_system( + cls, system: TaxBenefitSystem + ) -> HouseholdModelMetadata: + """Read the metadata the Public API builds from a tax-benefit system. + + Variables keep the system's order. Parameters are every descendant + whose name starts with ``gov``, as the Public API's metadata lists. + """ + entities = { + entity.key: HouseholdEntity( + key=entity.key, + plural=entity.plural, + role_plurals=frozenset( + role.plural + for role in getattr(entity, "roles", ()) + if role.plural is not None + ), + ) + for entity in system.entities + } + variables = { + name: HouseholdVariable( + name=name, + entity=variable.entity.key, + definition_period=variable.definition_period, + is_input=variable.is_input_variable(), + default_value=_metadata_default_value(variable), + ) + for name, variable in system.variables.items() + } + root = system.parameters + parameters = frozenset( + parameter.name + for parameter in (root.get_descendants() if root is not None else ()) + if parameter.name[:3] == "gov" + ) + return cls(variables=variables, entities=entities, parameters=parameters) + + def entity_by_plural(self) -> dict[str, HouseholdEntity]: + """Return the entities keyed by their plural.""" + return {entity.plural: entity for entity in self.entities.values()} + + +def _metadata_default_value(variable: Variable) -> bool | int | float | str | None: + default_value: object = variable.default_value + if variable.value_type.__name__ == "Enum": + return str(getattr(default_value, "name", default_value)) + if isinstance(default_value, (int, float, bool)): + return default_value + return None + + +def assign_request_year(situation: dict[str, Any], *, year: str) -> None: + """Put every value and axis the household gives without a period in ``year``. + + Changes ``situation`` in place: a variable given as one value, not by + period, becomes that value for ``year``, and an axis without a ``period`` + varies ``year``. The country model would otherwise put both in its own + default year (currently 2024 for the US model and 2025 for the UK model), + whatever year the request names, which is where the Public API leaves + them. Membership lists are left alone. + """ + for entity_plural, entity_group in situation.items(): + if entity_plural == _AXES_KEY: + _put_axes_in_year(entity_group, year) + elif isinstance(entity_group, dict): + for variables in entity_group.values(): + if isinstance(variables, dict): + _put_single_values_in_year(variables, year) + + +def _put_single_values_in_year(variables: dict[str, Any], year: str) -> None: + for variable_name, value in variables.items(): + if value is None or isinstance(value, (bool, int, float, str)): + variables[variable_name] = {year: value} + + +def _put_axes_in_year(axes: object, year: str) -> None: + if not isinstance(axes, list): + return + for group in axes: + for axis in group: + if isinstance(axis, dict) and axis.get("period") is None: + axis["period"] = year + + +def add_missing_variables( + situation: dict[str, Any], + metadata: HouseholdModelMetadata, + *, + year: str, +) -> None: + """Add every yearly, monthly and eternal variable each entity lacks, in place. + + Input variables get their default value and calculated variables get + None, which requests them, for ``year``, the request's calculation year. + The Public API's ``/calculate-full`` route adds the same variables, for + the first age period of a person named ``you`` or else the current year. + """ + for variable in metadata.variables.values(): + if variable.definition_period not in _ADDED_DEFINITION_PERIODS: + continue + entity_plural = metadata.entities[variable.entity].plural + for entity in situation.get(entity_plural, {}).values(): + if variable.name not in entity: + entity[variable.name] = { + year: variable.default_value if variable.is_input else None + } + + +@dataclass(frozen=True) +class DeprecatedVariableWarning: + """A removed or renamed variable was supplied and dropped before calculation.""" + + variable: str + entity_plural: str + entity_id: str + hint: str + + @property + def message(self) -> str: + """Return the warning naming the ignored input, where it was and the hint.""" + location = f"`{self.entity_plural}/{self.entity_id}`" + if self.entity_plural == _AXES_KEY: + location = f"`axes[{self.entity_id}].name`" + return ( + f"Input `{self.variable}` on {location} is deprecated and was " + f"ignored for this calculation. {self.hint}" + ) + + +@dataclass(frozen=True) +class DeprecatedInputsResult: + """A household copy without deprecated inputs, and one warning per input.""" + + household: dict[str, Any] + warnings: tuple[DeprecatedVariableWarning, ...] + + +def drop_deprecated_inputs(situation: Mapping[str, Any]) -> DeprecatedInputsResult: + """Return a copy of ``situation`` without deprecated inputs, with warnings. + + Each deprecated variable on an entity, and each axis that varies one, is + removed with one warning; an axis group left empty is removed, and + ``axes`` is removed when no group is left. ``situation`` is not changed. + """ + warnings: list[DeprecatedVariableWarning] = [] + household = copy.deepcopy(dict(situation)) + for entity_plural, entity_group in household.items(): + if entity_plural == _AXES_KEY or not isinstance(entity_group, dict): + continue + for entity_id, variables in entity_group.items(): + if isinstance(variables, dict): + warnings.extend( + _drop_deprecated_variables(entity_plural, entity_id, variables) + ) + _drop_deprecated_axes(household, warnings) + return DeprecatedInputsResult(household=household, warnings=tuple(warnings)) + + +def _drop_deprecated_variables( + entity_plural: str, entity_id: str, variables: dict[str, Any] +) -> list[DeprecatedVariableWarning]: + warnings: list[DeprecatedVariableWarning] = [] + for variable_name in list(variables): + hint = DEPRECATED_VARIABLES.get(variable_name) + if hint is None: + continue + warnings.append( + DeprecatedVariableWarning( + variable=variable_name, + entity_plural=entity_plural, + entity_id=entity_id, + hint=hint, + ) + ) + del variables[variable_name] + return warnings + + +def _drop_deprecated_axes( + household: dict[str, Any], warnings: list[DeprecatedVariableWarning] +) -> None: + axes = household.get(_AXES_KEY) + if not isinstance(axes, list): + return + changed = False + retained_groups = [] + for group_index, group in enumerate(axes): + retained_axes = [] + for axis_index, axis in enumerate(group): + warning = _deprecated_axis_warning(axis, f"{group_index}][{axis_index}") + if warning is None: + retained_axes.append(axis) + continue + warnings.append(warning) + changed = True + if retained_axes: + retained_groups.append(retained_axes) + if not changed: + return + if retained_groups: + household[_AXES_KEY] = retained_groups + else: + del household[_AXES_KEY] + + +def _deprecated_axis_warning( + axis: object, location: str +) -> DeprecatedVariableWarning | None: + if not isinstance(axis, dict): + return None + variable_name = str(axis.get("name")) + hint = DEPRECATED_VARIABLES.get(variable_name) + if hint is None: + return None + return DeprecatedVariableWarning( + variable=variable_name, entity_plural=_AXES_KEY, entity_id=location, hint=hint + ) + + +def _input_error_message( + input_type: InputErrorType, + name: str, + path: str, + entity_plurals: tuple[str, str] | None, +) -> str: + if input_type == "household_entity": + return f"Unrecognized household entity group `{name}` at `{path}`." + if input_type == "household_variable_wrong_entity" and entity_plurals: + expected, actual = entity_plurals + return ( + f"Household variable `{name}` belongs on `{expected}`, " + f"not `{actual}`, at `{path}`." + ) + if input_type == "household_axis_variable": + return f"Unrecognized household axis variable `{name}` at `{path}`." + if input_type == "policy_parameter": + return f"Unrecognized policy parameter `{name}` at `{path}`." + return f"Unrecognized household variable `{name}` at `{path}`." + + +def _input_error( + input_type: InputErrorType, + name: str, + path: str, + *, + entity_plurals: tuple[str, str] | None = None, +) -> HouseholdInputError: + """Return one unrecognized input, worded as the Public API words it. + + ``entity_plurals`` is the (expected, actual) pair for a variable placed on + the wrong entity. + """ + expected, actual = entity_plurals or (None, None) + return HouseholdInputError( + type=input_type, + name=name, + path=path, + message=_input_error_message(input_type, name, path, entity_plurals), + expected_entity_plural=expected, + actual_entity_plural=actual, + ) + + +def find_unrecognized_household_inputs( + situation: Mapping[str, Any], + metadata: HouseholdModelMetadata, +) -> list[HouseholdInputError]: + """Find unknown entity groups, variables and axis variables, in document order. + + Also reports a known variable placed on the wrong entity. Role membership + keys (such as ``members``) are not variables and are skipped. + """ + errors: list[HouseholdInputError] = [] + entity_by_plural = metadata.entity_by_plural() + for entity_plural, entity_group in situation.items(): + if entity_plural == _AXES_KEY: + errors.extend(_find_unrecognized_axes(entity_group, metadata)) + continue + entity = entity_by_plural.get(entity_plural) + if entity is None: + errors.append( + _input_error( + "household_entity", entity_plural, f"household.{entity_plural}" + ) + ) + continue + if not isinstance(entity_group, dict): + continue + for entity_id, entity_inputs in entity_group.items(): + if isinstance(entity_inputs, dict): + errors.extend( + _find_unrecognized_entity_inputs( + entity, entity_id, entity_inputs, metadata + ) + ) + return errors + + +def _find_unrecognized_entity_inputs( + entity: HouseholdEntity, + entity_id: str, + entity_inputs: Mapping[str, Any], + metadata: HouseholdModelMetadata, +) -> list[HouseholdInputError]: + errors: list[HouseholdInputError] = [] + for variable_name in entity_inputs: + if variable_name in entity.role_plurals: + continue + variable = metadata.variables.get(variable_name) + path = f"household.{entity.plural}.{entity_id}.{variable_name}" + if variable is None: + errors.append(_input_error("household_variable", variable_name, path)) + continue + expected_entity = metadata.entities.get(variable.entity) + if expected_entity is not None and expected_entity.plural != entity.plural: + errors.append( + _input_error( + "household_variable_wrong_entity", + variable_name, + path, + entity_plurals=(expected_entity.plural, entity.plural), + ) + ) + return errors + + +def _find_unrecognized_axes( + axes: object, metadata: HouseholdModelMetadata +) -> list[HouseholdInputError]: + if not isinstance(axes, list): + return [] + errors: list[HouseholdInputError] = [] + for group_index, group in enumerate(axes): + for axis_index, axis in enumerate(group): + if not isinstance(axis, dict) or "name" not in axis: + continue + variable_name = str(axis["name"]) + if variable_name in metadata.variables: + continue + errors.append( + _input_error( + "household_axis_variable", + variable_name, + f"household.axes[{group_index}][{axis_index}].name", + ) + ) + return errors + + +def find_unrecognized_policy_inputs( + parameters: Iterable[str], + metadata: HouseholdModelMetadata, +) -> list[HouseholdInputError]: + """Find policy parameter paths the country model does not have, in order.""" + return [ + _input_error("policy_parameter", name, f"policy.{name}") + for name in parameters + if name not in metadata.parameters + ] + + +def unrecognized_inputs_message(errors: Iterable[HouseholdInputError]) -> str: + """Join the errors' messages as the Public API's error response does.""" + return "Unrecognized calculate input(s): " + "; ".join( + error.message for error in errors + ) + + +def requested_computations( + situation: Mapping[str, Any], + *, + include_provided_values: bool, + variable_names: set[str] | frozenset[str] | None = None, +) -> list[RequestedValue]: + """List the (entity plural, entity name, variable, period) values to calculate. + + A value is requested when it is None or, with ``include_provided_values``, + whatever it is. ``variable_names`` keeps only those variables. Values are + listed in document order, as the Public API lists them. + """ + computations: list[RequestedValue] = [] + for entity_plural, entities in situation.items(): + if not isinstance(entities, dict): + continue + for entity_id, variables in entities.items(): + if isinstance(variables, dict): + computations.extend( + _requested_entity_values( + (entity_plural, entity_id), + variables, + include_provided_values=include_provided_values, + variable_names=variable_names, + ) + ) + return computations + + +def _requested_entity_values( + entity: tuple[str, str], + variables: Mapping[str, Any], + *, + include_provided_values: bool, + variable_names: set[str] | frozenset[str] | None, +) -> list[RequestedValue]: + entity_plural, entity_id = entity + return [ + (entity_plural, entity_id, variable_name, str(period)) + for variable_name, periods in variables.items() + if isinstance(periods, dict) + and (variable_names is None or variable_name in variable_names) + for period, value in periods.items() + if include_provided_values or value is None + ] diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py new file mode 100644 index 000000000..7f7ddad6d --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_spm.py @@ -0,0 +1,499 @@ +"""SPM selection and receipts for v2 household calculations. + +This reproduces the Public API's household SPM handling, so that a v2 +household result and error carry the same SPM codes, messages and receipt: + +- ``resolve_household_spm_selection`` checks a caller's settings, exactly as + sent, and merges them over the installed bundle's certified defaults, with + the Public API's error codes; +- ``household_spm_error_detail`` recognizes the SPM errors that end a + calculation; +- ``household_spm_provenance`` reduces the country model's receipt to the + compact public receipt and checks it matches the resolved selection. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Mapping +from functools import lru_cache +from typing import TYPE_CHECKING, Any, NoReturn + +from policyengine_simulation_contract.spm import ( + SPMErrorDetail, + SPMInputError, + SPMProvenance, + SPMRuntimeVersions, + SPMSelection, + build_spm_provenance, +) +from pydantic import ValidationError + +if TYPE_CHECKING: + from collections.abc import Callable + + from spm_calculator.rolling_forecast import SPMForecast + +# Country SPM errors about the household's own inputs. When the caller did not +# choose SPM settings, a value that needs one of these is left uncalculated +# instead of failing the calculation. +HOUSEHOLD_SPM_INPUT_ERROR_CODES = frozenset( + { + "SPM_GEOGRAPHY_REQUIRED", + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM_COMPOSITION_REQUIRED", + "SPM_YEAR_UNAVAILABLE", + } +) +_SUPPORTED_COUNTY_VINTAGE = "2020" +_CONFIGURATION_UNAVAILABLE = "SPM_CONFIGURATION_UNAVAILABLE" +_SETTINGS_INVALID = "SPM_SETTINGS_INVALID" +_SETTINGS_UNSUPPORTED = "SPM_SETTINGS_UNSUPPORTED" + + +# The contract's SPM receipt model, which the main path shares, words these +# errors differently from the Public API's model. The household path reports +# the Public API's wording without changing the shared model. +_PUBLIC_API_RECEIPT_MESSAGES = { + "SPM provenance years must use four-digit years": ( + "years must contain four-digit calendar years" + ), + "SPM provenance years must be sorted and unique": "years must be sorted and unique", + "An SPM area receipt requires geography_id": ( + "A metro SPM receipt requires geography_id" + ), + "Only an SPM area receipt accepts geography_id": ( + "Only a metro SPM receipt accepts geography_id" + ), +} + + +def _readable_error(error: BaseException) -> str: + """Render a validation error as the Public API renders it, without its URL.""" + if not isinstance(error, ValidationError): + return str(error) + return "; ".join( + ": ".join( + part + for part in ( + ".".join(str(item) for item in detail["loc"]), + _public_api_message(detail["msg"].removeprefix("Value error, ")), + ) + if part + ) + for detail in error.errors() + ) + + +def _public_api_message(message: str) -> str: + return _PUBLIC_API_RECEIPT_MESSAGES.get(message, message) + + +def _current_bundle() -> object: + """Return the installed PolicyEngine.py bundle manifest.""" + from policyengine.bundle import get_current_bundle + + return get_current_bundle() + + +def _bundle_spm_configuration() -> object: + """Return the installed bundle's SPM measurement configuration, if any. + + Raises SPMInputError when the bundle manifest cannot be read. + """ + try: + bundle = _current_bundle() + except (AttributeError, ImportError, OSError, TypeError, ValueError): + bundle = None + if not isinstance(bundle, Mapping): + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, "The installed bundle manifest is invalid" + ) + measurements = bundle.get("measurements") + return measurements.get("spm") if isinstance(measurements, Mapping) else None + + +def _simulation_supports_spm(simulation_type: Callable[..., object]) -> bool: + """Whether a country ``Simulation`` constructor implements canonical SPM.""" + try: + parameters = inspect.signature(simulation_type).parameters + except (TypeError, ValueError): + return False + return "spm" in parameters or ( + hasattr(simulation_type, "spm_config") + and callable(getattr(simulation_type, "spm_provenance", None)) + ) + + +def _us_model_supports_spm() -> bool: + """Whether the installed US model implements canonical SPM. + + A model this build cannot import has no canonical SPM. + """ + try: + from policyengine_us import Simulation + except (AttributeError, ImportError, OSError, TypeError, ValueError): + return False + return _simulation_supports_spm(Simulation) + + +@lru_cache(maxsize=4) +def certified_forecast(expected_sha256: str) -> SPMForecast: + """Return the packaged SPM forecast whose content has ``expected_sha256``. + + Loaded once per process and digest; raises when the packaged forecast has + another digest. The household path keeps this loader apart from the main + path's SPM module. + """ + from spm_calculator import load_forecast + + return load_forecast(expected_sha256=expected_sha256) + + +def _certified_defaults(configured: object) -> tuple[SPMSelection, SPMForecast]: + """Return the bundle's pinned SPM defaults and the forecast they name. + + Raises ValueError (or an import or loading error) when the configuration + pins no forecast hash or scenario, or the US model lacks canonical SPM. + """ + defaults = SPMSelection.model_validate(configured) + if defaults.forecast_content_sha256 is None or defaults.scenario is None: + msg = "The bundle must pin the SPM artifact hash and scenario" + raise ValueError(msg) + if not _us_model_supports_spm(): + msg = "The installed US model does not support canonical SPM" + raise ValueError(msg) + return defaults, certified_forecast(defaults.forecast_content_sha256) + + +def _validated_selection(values: dict[str, Any], forecast: SPMForecast) -> SPMSelection: + """Validate merged settings against the forecast; raise ValueError if invalid.""" + resolved = SPMSelection.model_validate(values) + forecast.entry(forecast.years[0], scenario=resolved.scenario, as_of=resolved.as_of) + if resolved.county_vintage != _SUPPORTED_COUNTY_VINTAGE: + msg = "Unsupported county vintage: use 2020" + raise ValueError(msg) + return resolved + + +def _validate_metro_selection(forecast: SPMForecast, selection: SPMSelection) -> None: + """Raise SPM_GEOGRAPHY_UNAVAILABLE unless the area is in some forecast year.""" + for year in forecast.years: + try: + forecast.geography_factor( + year, + "renter", + scenario=selection.scenario, + kind="metro", + geoid=selection.geography_id, + as_of=selection.as_of, + ) + except ValueError: + continue + return + error_code = "SPM_GEOGRAPHY_UNAVAILABLE" + raise SPMInputError( + error_code, + f"SPM area is unavailable in the selected artifact: {selection.geography_id}", + ) + + +def _merged_selection(chosen: SPMSelection, defaults: SPMSelection) -> dict[str, Any]: + """Overlay the caller's choices on the defaults, as the Public API does. + + Changing the geography kind also replaces an inherited geography ID, and + the forecast hash always comes from the bundle. + """ + values = {name: getattr(defaults, name) for name in SPMSelection.model_fields} + values.update(chosen.model_dump(exclude_unset=True)) + if ( + "geography_kind" in chosen.model_fields_set + and chosen.geography_kind != defaults.geography_kind + ): + values["geography_id"] = chosen.geography_id + values["forecast_content_sha256"] = defaults.forecast_content_sha256 + values["scenario"] = chosen.scenario or defaults.scenario + return values + + +def _certified_configuration(*, selection_sent: bool) -> Mapping[str, object] | None: + """Return the bundle's certified SPM settings, or None for a legacy bundle. + + Raises SPM_CONFIGURATION_UNAVAILABLE for an unreadable bundle or for a US + model that can run canonical SPM without a certified configuration, and + SPM_SETTINGS_UNSUPPORTED when settings were sent to a legacy bundle. + """ + configured = _bundle_spm_configuration() + if isinstance(configured, Mapping): + return configured + if _us_model_supports_spm(): + # A model that can run canonical SPM must never run it uncertified. + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, + "The installed bundle has no certified SPM measurement configuration", + ) + if not selection_sent: + return None + raise SPMInputError( + _SETTINGS_UNSUPPORTED, + "This US model bundle does not support canonical SPM settings", + ) + + +def _raise_unavailable_forecast(*, saved_household: bool) -> NoReturn: + """Reject a selection naming a forecast this bundle does not have.""" + if saved_household: + # A saved selection is the household's identity, not a request: this + # deployment lacks the forecast it was measured against. + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, + "This deployment does not have the SPM artifact this household's " + "saved selection was measured against", + ) + raise SPMInputError( + _SETTINGS_INVALID, + "SPM selection does not match this bundle's artifact hash", + ) + + +def _resolve_certified_selection( + chosen: SPMSelection, + configured: Mapping[str, object], + *, + saved_household: bool, +) -> dict[str, Any]: + """Merge the caller's settings over the certified defaults and validate them.""" + try: + defaults, forecast = _certified_defaults(configured) + except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, _readable_error(error) + ) from error + if chosen.forecast_content_sha256 not in (None, defaults.forecast_content_sha256): + _raise_unavailable_forecast(saved_household=saved_household) + try: + resolved = _validated_selection(_merged_selection(chosen, defaults), forecast) + except ValueError as error: + raise SPMInputError(_SETTINGS_INVALID, _readable_error(error)) from error + if resolved.geography_kind == "metro": + _validate_metro_selection(forecast, resolved) + return resolved.model_dump(mode="json") + + +def _requested_selection(selection: object) -> SPMSelection: + """Read the caller's settings; raise SPM_SETTINGS_INVALID if they are malformed. + + As in the Public API, the area rule applies to the settings as sent, with + an omitted geography kind read as county: a metro selection needs an area + and any other kind accepts none. The contract's model checks that rule + only once both fields are set, because Stage 12 requests inherit omitted + fields first. + """ + try: + chosen = SPMSelection.model_validate({} if selection is None else selection) + except ValidationError as error: + raise SPMInputError(_SETTINGS_INVALID, _readable_error(error)) from error + if chosen.geography_kind == "metro": + if not chosen.geography_id or not chosen.geography_id.strip(): + raise SPMInputError( + _SETTINGS_INVALID, "An SPM area selection requires geography_id" + ) + elif chosen.geography_id is not None: + raise SPMInputError( + _SETTINGS_INVALID, "Only an SPM area selection accepts geography_id" + ) + return chosen + + +def resolve_household_spm_selection( + country: str, + selection: object, + *, + sent: bool, + saved_household: bool, +) -> dict[str, Any] | None: + """Resolve a household's SPM settings, exactly as sent, against the bundle. + + ``sent`` says whether the caller sent settings at all; ``selection`` is + what it sent. Returns the complete resolved selection, or None when no SPM + applies (any country but the US, or a bundle whose US model predates + canonical SPM). Raises SPMInputError with the Public API's code and + message otherwise, checking in its order: settings for another country, + an explicit null, malformed settings, then the bundle's certification. + ``saved_household`` says the settings were read from a saved household, + which changes only how a forecast hash the bundle no longer carries is + reported. + """ + if country != "us": + if sent: + raise SPMInputError( + _SETTINGS_UNSUPPORTED, "SPM settings are only available for the US" + ) + return None + if sent and selection is None: + raise SPMInputError( + _SETTINGS_INVALID, + "spm must be an object; omit it to inherit the certified defaults.", + ) + chosen = _requested_selection(selection) + configured = _certified_configuration(selection_sent=sent) + if configured is None: + return None + return _resolve_certified_selection( + chosen, configured, saved_household=saved_household + ) + + +def household_spm_error_detail(error: BaseException) -> SPMErrorDetail | None: + """Return the SPM error that ends a calculation, following wrapped causes. + + Recognizes this module's own ``SPMInputError`` and a country model's error + carrying one of ``HOUSEHOLD_SPM_INPUT_ERROR_CODES``, as the Public API does. + """ + visited: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in visited: + visited.add(id(current)) + code = getattr(current, "code", None) + if isinstance(current, SPMInputError) or ( + isinstance(current, ValueError) and code in HOUSEHOLD_SPM_INPUT_ERROR_CODES + ): + return SPMErrorDetail(code=str(code), message=str(current)) + current = current.__cause__ or current.__context__ + return None + + +def _required_text(source: Mapping[str, object], field_name: str) -> str: + value = source.get(field_name) + if not isinstance(value, str) or not value: + msg = f"Country SPM receipt requires {field_name}" + raise ValueError(msg) + return value + + +def _complete_settings(settings: object) -> SPMSelection | None: + """Return the selection only when every field is present.""" + try: + selection = SPMSelection.model_validate(settings) + except ValidationError: + return None + if set(SPMSelection.model_fields) - selection.model_fields_set: + return None + return selection + + +def _receipt_mapping(receipt: Mapping[str, object], field_name: str) -> Mapping: + value = receipt.get(field_name) + if not isinstance(value, Mapping): + msg = f"Country SPM receipt requires {field_name}" + raise ValueError(msg) # noqa: TRY004 # only ValueError means an uncertifiable receipt, as in the Public API + return value + + +def _receipt_years(receipt: Mapping[str, object]) -> tuple[str, ...]: + """Return the receipt's years, sorted; raise ValueError if they are unusable.""" + source_years = receipt.get("years") + if not isinstance(source_years, Mapping): + msg = "Country SPM receipt requires a years mapping" + raise ValueError(msg) # noqa: TRY004 # only ValueError means an uncertifiable receipt, as in the Public API + if any(not isinstance(year, str) for year in source_years): + msg = "Country SPM receipt years must be strings" + raise ValueError(msg) + return tuple(sorted(source_years)) + + +def _compact_receipt( + selection: SPMSelection, receipt: Mapping[str, object] +) -> SPMProvenance: + """Reduce the country model's rich receipt to the compact public receipt. + + Checks run in the Public API's order, so the first problem reported is the + one it reports: the required texts, their match with the selection, the + years, the runtime versions, then the forecast ID. + """ + forecast_sha256 = _required_text(receipt, "forecast_sha256") + scenario = _required_text(receipt, "scenario") + geography_kind = _required_text(receipt, "geography_kind") + if ( + forecast_sha256 != selection.forecast_content_sha256 + or scenario != selection.scenario + or geography_kind != selection.geography_kind + ): + msg = "Country SPM receipt differs from the SPM configuration" + raise ValueError(msg) + years = _receipt_years(receipt) + runtime_versions = SPMRuntimeVersions.model_validate( + _receipt_mapping(receipt, "runtime_versions") + ) + return build_spm_provenance( + forecast_id=_required_text(receipt, "forecast_id"), + forecast_sha256=forecast_sha256, + selection=selection, + years=years, + runtime_versions=runtime_versions, + ) + + +def _country_receipt(simulation: object) -> SPMProvenance: + """Reduce the simulation's receipt. + + Raises ValueError if the receipt cannot be certified. A simulation with + ``spm_config`` but no ``spm_provenance`` method raises AttributeError, as + in the Public API. + """ + selection = _complete_settings(getattr(simulation, "spm_config", None)) + if selection is None: + msg = "Country SPM configuration is incomplete" + raise ValueError(msg) + source = simulation.spm_provenance() # pyright: ignore[reportAttributeAccessIssue] # a country Simulation with spm_config has this method + if not isinstance(source, Mapping): + msg = "Country SPM receipt must be an object" + raise ValueError(msg) # noqa: TRY004 # only ValueError means an uncertifiable receipt, as in the Public API + return _compact_receipt(selection, source) + + +def _calculation_receipt(simulation: object) -> SPMProvenance | None: + """Return the simulation's compact SPM receipt; None without SPM support.""" + if not hasattr(simulation, "spm_config"): + return None + try: + return _country_receipt(simulation) + except ValueError as error: + raise SPMInputError( + _CONFIGURATION_UNAVAILABLE, + "The installed country model reported an SPM receipt this API cannot " + f"certify: {_readable_error(error)}", + ) from error + + +def household_spm_provenance( + simulation: object, + expected_selection: Mapping[str, object] | None, +) -> SPMProvenance | None: + """Return the simulation's SPM receipt, checked against the resolved selection. + + Raises SPMInputError for a receipt that cannot be certified, and + ValueError for a receipt where none was expected, a missing receipt, or a + receipt for another selection. + """ + receipt = _calculation_receipt(simulation) + if expected_selection is None: + if receipt is None: + return None + msg = "Unexpected SPM provenance for a calculation without SPM" + raise ValueError(msg) + if receipt is None: + msg = "The calculation produced no SPM receipt" + raise ValueError(msg) + receipt_selection = { + "forecast_content_sha256": receipt.forecast_sha256, + "scenario": receipt.scenario, + "geography_kind": receipt.geography_kind, + "geography_id": receipt.geography_id, + "county_vintage": receipt.county_vintage, + "as_of": receipt.as_of.isoformat() if receipt.as_of is not None else None, + } + if receipt_selection != dict(expected_selection): + msg = "SPM calculation configuration differs from the request" + raise ValueError(msg) + return receipt diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py new file mode 100644 index 000000000..02510a0f3 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/household_validation.py @@ -0,0 +1,94 @@ +"""The household that checks a v2 household function calculates. + +Release validation calls each deployed household function with this +household, and the pre-merge image check calculates it inside the household +image. Both use ``check_household_validation`` to decide whether it worked. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, + HouseholdExecutionResult, + parse_household_execution_outcome, +) + +from policyengine_simulation_executor.household_calculation import ( + installed_household_bundle, +) + +if TYPE_CHECKING: + from policyengine_simulation_contract.household_execution import ( + HouseholdWorkerIdentity, + ) + from policyengine_simulation_contract.stage12_bundle import CountryId + +# The group entity, besides the household, that holds the adult in each +# country's validation household. +_ADULT_GROUP_PLURALS: dict[str, str] = {"us": "spm_units", "uk": "benunits"} + + +def household_validation_request( + country: CountryId, *, year: int +) -> HouseholdExecutionRequest: + """Return a one-adult, current-law household request for ``country``. + + The request names the installed bundle and ``year``, and asks for the + household's net income in that year. + """ + period = str(year) + household = { + "people": {"you": {"age": {period: 40}, "employment_income": {period: 30_000}}}, + _ADULT_GROUP_PLURALS[country]: {"your unit": {"members": ["you"]}}, + "households": { + "your household": { + "members": ["you"], + "household_net_income": {period: None}, + } + }, + } + return HouseholdExecutionRequest.model_validate( + { + "calculation_id": uuid4(), + "country": country, + "year": period, + "household": household, + "policies": [{"policy_key": "baseline", "parameters": {}}], + "bundle": installed_household_bundle(country), + } + ) + + +def check_household_validation( + outcome: object, + *, + request: HouseholdExecutionRequest, + worker: HouseholdWorkerIdentity, +) -> float: + """Return the household net income ``outcome`` calculated for ``request``. + + Raises RuntimeError unless the outcome is a success with the request's + bundle, reported by ``worker``, with a number for the household's net + income. + """ + name = worker.function_name + parsed = parse_household_execution_outcome(outcome) + if not isinstance(parsed, HouseholdExecutionResult): + msg = f"{name} did not calculate the validation household" + raise RuntimeError(msg) # noqa: TRY004 # a failed calculation fails the check; it is not a type error + if parsed.output.bundle != request.bundle: + msg = f"{name} calculated with another bundle" + raise RuntimeError(msg) + if parsed.worker != worker: + msg = f"{name} reported another function" + raise RuntimeError(msg) + situation = parsed.output.results[0].household.to_situation() + net_income = situation["households"]["your household"]["household_net_income"] + values = list(net_income.values()) if isinstance(net_income, dict) else [] + if len(values) != 1 or not isinstance(values[0], float): + msg = f"{name} returned no household net income" + raise RuntimeError(msg) + return values[0] diff --git a/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py b/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py index 20e240bc1..560e5334e 100644 --- a/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py +++ b/projects/policyengine-simulation-executor/tests/integration/test_image_smoke_modal.py @@ -38,3 +38,10 @@ def test_stage12_static_runtime_file_image_smoke(): result = _run_modal_smoke("src/modal/v2_static_runtime_files_smoke.py") assert result.returncode == 0, result.stdout + result.stderr assert "Stage 12 static runtime file smoke OK" in result.stdout + + +@pytest.mark.integration +def test_v2_image_smoke(): + result = _run_modal_smoke("src/modal/v2_image_smoke.py") + assert result.returncode == 0, result.stdout + result.stderr + assert "v2 image smoke OK" in result.stdout diff --git a/projects/policyengine-simulation-executor/tests/test_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_household_calculation.py b/projects/policyengine-simulation-executor/tests/test_household_calculation.py new file mode 100644 index 000000000..bbc777adf --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_calculation.py @@ -0,0 +1,1092 @@ +"""Tests for v2 household calculations, ported from the Public API's behaviour. + +The first tests use a stand-in country model to cover the failure paths. The +later ones calculate with the installed US and UK models, as the Public API's +in-process calculation does; the US model takes about a minute to load once. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from datetime import UTC, date, datetime +from types import SimpleNamespace +from uuid import UUID + +import numpy as np +import pytest +from policyengine_core.errors import SituationParsingError +from policyengine_simulation_contract.household_execution import ( + HouseholdBundleProvenance, + HouseholdExecutionFailure, + HouseholdExecutionRequest, + HouseholdExecutionResult, + HouseholdWorkerIdentity, +) +from policyengine_simulation_contract.spm import SPMInputError +from policyengine_simulation_executor import household_calculation +from policyengine_simulation_executor.household_calculation import ( + CountryHouseholdModel, + HouseholdCalculationDependencies, + calculate_household, + installed_household_bundle, + load_country_household_model, +) +from policyengine_simulation_executor.household_inputs import ( + HouseholdEntity, + HouseholdModelMetadata, + HouseholdVariable, +) + +CALCULATION_ID = UUID("00000000-0000-4000-8000-0000000000c1") + + +def _worker(country: str) -> HouseholdWorkerIdentity: + return HouseholdWorkerIdentity( + application_name="policyengine-simulation-v2-py6-7-5", + function_name=f"calculate_household_{country}", + ) + + +def _request(country: str, household: dict, **overrides) -> HouseholdExecutionRequest: + values = { + "calculation_id": CALCULATION_ID, + "country": country, + "year": "2026", + "household": household, + "policies": [{"policy_key": "baseline", "parameters": {}}], + "bundle": installed_household_bundle(country).model_dump(mode="json"), + } + values.update(overrides) + return HouseholdExecutionRequest.model_validate(values) + + +class RecordingRuntime: + """Records the spans a calculation opens, in order, and which are open.""" + + def __init__(self) -> None: + self.spans: list[tuple[str, dict]] = [] + self.open_spans: list[str] = [] + + @contextmanager + def span(self, name: str, *, attributes: dict | None = None): + self.spans.append((name, dict(attributes or {}))) + self.open_spans.append(name) + try: + yield + finally: + self.open_spans.pop() + + +# A stand-in country model: enough of a country package to exercise the +# failure paths without importing a real one. + + +class StandInPopulation: + def __init__(self, ids: list[str], count: int) -> None: + self.ids = ids + self.count = count + + def get_index(self, entity_id: str) -> int: + return self.ids.index(entity_id) + + +class StandInSystem: + def __init__(self, value_types: dict[str, type]) -> None: + self.value_types = value_types + self.variables = dict.fromkeys(value_types) + + def get_variable(self, name: str): + return SimpleNamespace(value_type=self.value_types[name]) + + +STAND_IN_VALUE_TYPES = { + "age": int, + "net_income": float, + "spm_resources": float, + "registration_date": date, +} + + +def _stand_in_model(simulation_type) -> CountryHouseholdModel: + metadata = HouseholdModelMetadata( + variables={ + name: HouseholdVariable( + name, + "person" if name == "age" else "household", + "year", + is_input=name == "age", + default_value=40 if name == "age" else None, + ) + for name in STAND_IN_VALUE_TYPES + }, + entities={ + "person": HouseholdEntity("person", "people", frozenset()), + "household": HouseholdEntity( + "household", "households", frozenset({"members"}) + ), + }, + parameters=frozenset(), + ) + system = StandInSystem(STAND_IN_VALUE_TYPES) + simulation_type.tax_benefit_system = system + return CountryHouseholdModel( + country="uk", + simulation_type=simulation_type, + tax_benefit_system=system, + metadata=metadata, + ) + + +class CountryInputError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def _stand_in_simulation(outcomes: dict[str, object], *, axis_points: int = 1): + class StandInSimulation: + tax_benefit_system: StandInSystem + + def __init__(self, *, situation: dict, **options: object) -> None: + self.situation = situation + self.options = options + people = list(situation["people"]) + households = list(situation.get("households", {})) + self.populations = { + "people": StandInPopulation(people, len(people) * axis_points), + "households": StandInPopulation( + households, len(households) * axis_points + ), + } + + def get_population(self, plural: str) -> StandInPopulation: + return self.populations[plural] + + def calculate(self, variable_name: str, period: str): + outcome = outcomes[variable_name] + if isinstance(outcome, Exception): + raise outcome + return outcome + + return StandInSimulation + + +STAND_IN_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 30}}}, + "households": { + "home": { + "members": ["you"], + "net_income": {"2026": None}, + "spm_resources": {"2026": None}, + } + }, +} + + +def _calculate_with_stand_in( + simulation_type, household=None, *, country="uk", **overrides +): + model = _stand_in_model(simulation_type) + return calculate_household( + _request(country, household or STAND_IN_HOUSEHOLD, **overrides), + worker=_worker(country), + dependencies=HouseholdCalculationDependencies( + model_loader=lambda _country: model + ), + ) + + +def test_a_value_that_cannot_be_calculated_is_null_without_a_warning() -> None: + outcome = _calculate_with_stand_in( + _stand_in_simulation( + {"net_income": np.array([1.5]), "spm_resources": KeyError("missing")} + ) + ) + + assert isinstance(outcome, HouseholdExecutionResult) + result = outcome.output.results[0] + home = result.household.to_situation()["households"]["home"] + assert home["net_income"] == {"2026": 1.5} + assert home["spm_resources"] == {"2026": None} + assert result.warnings == () + assert outcome.output.spm_config is None + assert result.spm_provenance is None + + +def test_with_axes_a_failed_value_is_one_null_per_point_with_a_warning() -> None: + household = { + **STAND_IN_HOUSEHOLD, + "axes": [[{"name": "age", "count": 3, "min": 0, "max": 2}]], + } + + outcome = _calculate_with_stand_in( + _stand_in_simulation( + { + "age": np.array([0, 1, 2]), + "net_income": np.array([1.0, float("inf"), float("nan")]), + "spm_resources": KeyError("missing"), + }, + axis_points=3, + ), + household, + ) + + assert isinstance(outcome, HouseholdExecutionResult) + result = outcome.output.results[0] + situation = result.household.to_situation() + assert situation["people"]["you"]["age"] == {"2026": [0, 1, 2]} + assert situation["households"]["home"]["net_income"] == { + "2026": [1.0, "Infinity", "NaN"] + } + assert situation["households"]["home"]["spm_resources"] == { + "2026": [None, None, None] + } + # The axis named no period, so it varied the request's year. + assert situation["axes"] == [[{**household["axes"][0][0], "period": "2026"}]] + assert result.warnings == ( + "Unable to calculate spm_resources for home in 2026; returned 3 null " + "axis values: 'missing'", + ) + + +def test_single_values_are_calculated_for_the_request_year() -> None: + # A value given once, not by period, is for the request's year, and a + # single null asks for that year's value, which the Public API leaves + # uncalculated. + created: list[dict] = [] + + class RecordingSimulation(_stand_in_simulation({"net_income": np.array([2.5])})): + def __init__(self, **options: object) -> None: + super().__init__(**options) + created.append(self.situation) + + household = { + "people": {"you": {"age": 30}}, + "households": {"home": {"members": ["you"], "net_income": None}}, + } + + outcome = _calculate_with_stand_in(RecordingSimulation, household, year="2024") + + assert isinstance(outcome, HouseholdExecutionResult), outcome + assert created[0]["people"]["you"]["age"] == {"2024": 30} + situation = outcome.output.results[0].household.to_situation() + assert situation["people"]["you"]["age"] == {"2024": 30} + assert situation["households"]["home"]["net_income"] == {"2024": 2.5} + + +def test_dates_are_written_as_the_public_api_writes_them() -> None: + # The Public API's Flask JSON encoder writes a date as an HTTP date. + household = { + "people": {"you": {"age": {"2026": 30}}}, + "households": { + "home": {"members": ["you"], "registration_date": {"2026": None}} + }, + } + + outcome = _calculate_with_stand_in( + _stand_in_simulation( + {"registration_date": np.array(["1970-01-01"], dtype="datetime64[D]")} + ), + household, + ) + + assert isinstance(outcome, HouseholdExecutionResult) + home = outcome.output.results[0].household.to_situation()["households"]["home"] + assert home["registration_date"] == {"2026": "Thu, 01 Jan 1970 00:00:00 GMT"} + + +def test_an_spm_input_error_fails_only_a_chosen_measurement(monkeypatch) -> None: + error = CountryInputError("SPM_GEOGRAPHY_REQUIRED", "SPM needs a county") + outcomes = {"net_income": np.array([1.0]), "spm_resources": error} + resolved = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + monkeypatch.setattr( + household_calculation, + "resolve_household_spm_selection", + lambda country, selection, *, sent, saved_household: dict(resolved), + ) + monkeypatch.setattr( + household_calculation, + "household_spm_provenance", + lambda simulation, expected_selection: None, + ) + + inherited = _calculate_with_stand_in(_stand_in_simulation(outcomes), country="us") + chosen = _calculate_with_stand_in( + _stand_in_simulation(outcomes), country="us", spm={} + ) + + assert isinstance(inherited, HouseholdExecutionResult) + home = inherited.output.results[0].household.to_situation()["households"]["home"] + assert home["spm_resources"] == {"2026": None} + assert inherited.output.spm_config is not None + assert inherited.output.spm_config.model_dump(mode="json") == resolved + assert isinstance(chosen, HouseholdExecutionFailure) + assert chosen.error.code == "invalid_spm_selection" + assert chosen.error.spm_error is not None + assert chosen.error.spm_error.code == "SPM_GEOGRAPHY_REQUIRED" + assert chosen.error.message == "SPM needs a county" + assert chosen.error.policy_key == "baseline" + + +def test_the_selection_and_its_receipt_reach_each_simulation(monkeypatch) -> None: + resolved = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + } + created: list[dict] = [] + receipts: list[tuple[object, object]] = [] + + class RecordingSimulation(_stand_in_simulation({"net_income": np.array([1.0])})): + def __init__(self, **options: object) -> None: + super().__init__(**options) + created.append(self.options) + + monkeypatch.setattr( + household_calculation, + "resolve_household_spm_selection", + lambda country, selection, *, sent, saved_household: resolved, + ) + + def record_receipt(simulation, expected_selection): + receipts.append((simulation, expected_selection)) + + monkeypatch.setattr( + household_calculation, "household_spm_provenance", record_receipt + ) + household = { + "people": {"you": {"age": {"2026": 30}}}, + "households": {"home": {"members": ["you"], "net_income": {"2026": None}}}, + } + + _calculate_with_stand_in(RecordingSimulation, household, country="us") + + assert [options["spm"] for options in created] == [resolved] + assert [expected for _, expected in receipts] == [resolved] + + +def test_an_spm_configuration_error_fails_even_an_inherited_measurement( + monkeypatch, +) -> None: + # Only a missing SPM input leaves an inherited measurement's value null; + # a configuration the build cannot certify always ends the calculation. + error = SPMInputError("SPM_CONFIGURATION_UNAVAILABLE", "no certified artifact") + monkeypatch.setattr( + household_calculation, + "resolve_household_spm_selection", + lambda country, selection, *, sent, saved_household: { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + }, + ) + + outcome = _calculate_with_stand_in( + _stand_in_simulation({"net_income": np.array([1.0]), "spm_resources": error}), + country="us", + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.spm_error is not None + assert outcome.error.spm_error.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert outcome.error.message == "no certified artifact" + + +@pytest.mark.parametrize( + ("simulation_error", "code", "message"), + [ + ( + SituationParsingError(["people"], "unknown person `ghost`"), + "invalid_household", + "Invalid household payload: ", + ), + ( + RuntimeError("model exploded"), + "calculation_failed", + "Error calculating household under policy: model exploded", + ), + ( + SPMInputError("SPM_SETTINGS_INVALID", "bad SPM settings"), + "invalid_spm_selection", + "bad SPM settings", + ), + ], +) +def test_simulation_build_failures_use_public_api_messages( + simulation_error: Exception, code: str, message: str +) -> None: + def failing_simulation(**_: object): + raise simulation_error + + outcome = _calculate_with_stand_in(failing_simulation) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == code + assert outcome.error.message.startswith(message) + assert outcome.error.policy_key == "baseline" + assert outcome.worker == _worker("uk") + assert outcome.calculation_id == CALCULATION_ID + + +@pytest.mark.parametrize( + ("country", "settings", "spm_code"), + [ + ("uk", None, "SPM_SETTINGS_UNSUPPORTED"), + ("us", None, "SPM_SETTINGS_INVALID"), + ("us", {"geography_kind": "planet"}, "SPM_SETTINGS_INVALID"), + ], +) +def test_spm_settings_as_sent_fail_with_public_api_codes( + country, settings, spm_code +) -> None: + outcome = _calculate_with_stand_in( + _stand_in_simulation({}), country=country, spm=settings + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.spm_error is not None + assert outcome.error.spm_error.code == spm_code + assert outcome.error.message == outcome.error.spm_error.message + assert outcome.error.policy_key is None + + +@pytest.mark.parametrize( + ("source", "saved_household"), [("request", False), ("saved_household", True)] +) +def test_spm_settings_are_resolved_while_preparing_the_household( + monkeypatch, source, saved_household +) -> None: + # The country model loads first, under its own span, so the time the US + # model takes to import is measured there and not in SPM resolution. + runtime = RecordingRuntime() + events: list[tuple[str, object]] = [] + model = _stand_in_model(_stand_in_simulation({"net_income": np.array([1.0])})) + + def load_model(country): + events.append(("model", list(runtime.open_spans))) + return model + + def resolve(country, selection, *, sent, saved_household): + events.append(("spm", list(runtime.open_spans))) + events.append(("spm options", (selection, sent, saved_household))) + + monkeypatch.setattr( + household_calculation, "resolve_household_spm_selection", resolve + ) + household = { + "people": {"you": {"age": {"2026": 30}}}, + "households": {"home": {"members": ["you"], "net_income": {"2026": None}}}, + } + + outcome = calculate_household( + _request("us", household, spm={}, spm_selection_source=source), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies( + model_loader=load_model, runtime=runtime + ), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + assert events == [ + ("model", ["v2_household_country_model_load"]), + ("spm", ["v2_household_input_normalization"]), + ("spm options", ({}, True, saved_household)), + ] + + +def test_a_receipt_that_cannot_be_certified_is_an_spm_failure(monkeypatch) -> None: + def uncertifiable(simulation, expected_selection): + code = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMInputError(code, "cannot certify") + + monkeypatch.setattr( + household_calculation, "household_spm_provenance", uncertifiable + ) + + outcome = _calculate_with_stand_in( + _stand_in_simulation( + {"net_income": np.array([1.0]), "spm_resources": np.array([1.0])} + ) + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.message == "cannot certify" + + +def test_unrecognized_inputs_are_listed_in_the_order_the_household_was_sent() -> None: + # As the Public API lists them: it walks the household as sent, here with + # ``axes`` first, so the axis error comes before the person's. + household = { + "axes": [[{"name": "not_an_axis_variable", "count": 2, "min": 0, "max": 1}]], + "people": {"you": {"age": {"2026": 30}, "not_a_variable": {"2026": 1}}}, + "households": {"home": {"members": ["you"], "net_income": {"2026": None}}}, + } + + outcome = _calculate_with_stand_in(_stand_in_simulation({}), household) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_inputs" + assert [error.path for error in outcome.error.input_errors] == [ + "household.axes[0][0].name", + "household.people.you.not_a_variable", + ] + assert outcome.error.message == ( + "Unrecognized calculate input(s): Unrecognized household axis variable " + "`not_an_axis_variable` at `household.axes[0][0].name`.; Unrecognized " + "household variable `not_a_variable` at " + "`household.people.you.not_a_variable`." + ) + + +def test_a_request_for_another_bundle_is_refused() -> None: + bundle = installed_household_bundle("uk").model_copy( + update={"policyengine_version": "0.0.1"} + ) + + outcome = _calculate_with_stand_in( + _stand_in_simulation({}), bundle=bundle.model_dump(mode="json") + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "bundle_mismatch" + assert outcome.error.policy_key is None + + +def test_a_function_refuses_another_countrys_household() -> None: + model = _stand_in_model(_stand_in_simulation({})) + + with pytest.raises(ValueError, match="calculates us households only"): + calculate_household( + _request("uk", STAND_IN_HOUSEHOLD), + worker=_worker("us"), + required_country="us", + dependencies=HouseholdCalculationDependencies( + model_loader=lambda _country: model + ), + ) + + +def test_the_installed_bundle_names_the_country_package() -> None: + bundle = installed_household_bundle("us") + + assert isinstance(bundle, HouseholdBundleProvenance) + assert bundle.country_package_name == "policyengine-us" + + +# Installed country models: real calculations, compared with the Public API's +# result shape. + +US_FAMILY = { + "people": { + "you": {"age": {"2026": 35}, "employment_income": {"2026": 30_000}}, + "your first dependent": {"age": {"2026": 5}}, + }, + "tax_units": { + "your tax unit": { + "members": ["you", "your first dependent"], + "ctc": {"2026": None}, + } + }, + "spm_units": {"your spm unit": {"members": ["you", "your first dependent"]}}, + "families": {"your family": {"members": ["you", "your first dependent"]}}, + "marital_units": { + "your marital unit": {"members": ["you"]}, + "your first dependent's marital unit": {"members": ["your first dependent"]}, + }, + "households": { + "your household": { + "members": ["you", "your first dependent"], + "state_name": {"2026": "TX"}, + "county_fips": {"2026": "48201"}, + "household_net_income": {"2026": None}, + } + }, +} +CTC_REFORM = { + "gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2100-12-31": 5_000} +} + + +def test_us_baseline_and_reform_are_calculated_in_one_request() -> None: + runtime = RecordingRuntime() + request = _request( + "us", + US_FAMILY, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": CTC_REFORM}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(runtime=runtime), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + assert outcome.calculation_id == CALCULATION_ID + assert outcome.output.bundle == installed_household_bundle("us") + baseline, reform = outcome.output.results + assert (baseline.policy_key, reform.policy_key) == ("baseline", "reform") + baseline_situation = baseline.household.to_situation() + reform_situation = reform.household.to_situation() + # Same structure as the input, with each requested value filled in. + assert list(baseline_situation) == list(US_FAMILY) + assert baseline_situation["people"] == US_FAMILY["people"] + baseline_ctc = baseline_situation["tax_units"]["your tax unit"]["ctc"]["2026"] + reform_ctc = reform_situation["tax_units"]["your tax unit"]["ctc"]["2026"] + assert isinstance(baseline_ctc, float) + assert isinstance(reform_ctc, float) + assert reform_ctc > baseline_ctc + # A US household inherits the certified SPM selection and carries its receipt. + assert outcome.output.spm_config is not None + assert outcome.output.spm_config.geography_kind == "county" + assert baseline.spm_provenance is not None + assert reform.spm_provenance is not None + assert baseline.spm_provenance.scenario == outcome.output.spm_config.scenario + assert [name for name, _ in runtime.spans] == [ + "v2_household_country_model_load", + "v2_household_input_normalization", + "v2_household_calculation", + "v2_household_calculation", + ] + assert runtime.spans[3][1] == {"country": "us", "policy_key": "reform"} + + +def test_us_axes_calculate_one_value_per_point() -> None: + household = { + **US_FAMILY, + "axes": [ + [ + { + "name": "employment_income", + "count": 5, + "min": 0, + "max": 80_000, + "period": "2026", + } + ] + ], + } + + outcome = calculate_household( + _request("us", household), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + incomes = situation["households"]["your household"]["household_net_income"] + assert len(incomes["2026"]) == 5 + assert all(isinstance(income, float) for income in incomes["2026"]) + assert incomes["2026"][0] < incomes["2026"][4] + # With axes, every value of a known variable is calculated at each point, + # and the people's provided values are the axis values. + assert situation["people"]["you"]["employment_income"] == { + "2026": [0.0, 20_000.0, 40_000.0, 60_000.0, 80_000.0] + } + assert situation["people"]["you"]["age"] == {"2026": [35.0] * 5} + assert situation["households"]["your household"]["state_name"] == { + "2026": ["TX"] * 5 + } + assert situation["axes"] == household["axes"] + + +def test_us_calculate_full_adds_and_calculates_every_missing_variable() -> None: + outcome = calculate_household( + _request("us", US_FAMILY, add_missing_variables=True), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + you = situation["people"]["you"] + tax_unit = situation["tax_units"]["your tax unit"] + model = load_country_household_model("us") + person_variables = { + variable.name + for variable in model.metadata.variables.values() + if variable.entity == "person" + and variable.definition_period in {"year", "month", "eternity"} + } + assert person_variables <= set(you) + assert isinstance(you["is_blind"]["2026"], bool) + assert isinstance(tax_unit["income_tax"]["2026"], float) + assert isinstance(tax_unit["eitc"]["2026"], float) + assert isinstance( + situation["households"]["your household"]["household_net_income"]["2026"], + float, + ) + + +US_SINGLE_WITHOUT_COUNTY = { + "people": {"you": {"age": {"2026": 35}, "employment_income": {"2026": 30_000}}}, + "spm_units": { + "your spm unit": { + "members": ["you"], + "spm_unit_spm_threshold": {"2026": None}, + } + }, + "households": { + "your household": {"members": ["you"], "state_name": {"2026": "TX"}} + }, +} + + +def test_us_a_chosen_spm_selection_must_have_its_geography() -> None: + # The certified defaults measure by county. Inherited, a household without + # a county leaves the SPM threshold uncalculated; chosen, it is an error. + def calculate(**spm_settings): + return calculate_household( + _request("us", US_SINGLE_WITHOUT_COUNTY, **spm_settings), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + inherited = calculate() + chosen = calculate(spm={}) + national = calculate(spm={"geography_kind": "national"}) + + assert isinstance(inherited, HouseholdExecutionResult), inherited + unit = inherited.output.results[0].household.to_situation()["spm_units"] + assert unit["your spm unit"]["spm_unit_spm_threshold"] == {"2026": None} + assert isinstance(chosen, HouseholdExecutionFailure) + assert chosen.error.code == "invalid_spm_selection" + assert chosen.error.spm_error is not None + assert chosen.error.spm_error.code == "SPM_GEOGRAPHY_REQUIRED" + assert isinstance(national, HouseholdExecutionResult), national + unit = national.output.results[0].household.to_situation()["spm_units"] + assert isinstance(unit["your spm unit"]["spm_unit_spm_threshold"]["2026"], float) + assert national.output.spm_config is not None + assert national.output.spm_config.geography_kind == "national" + receipt = national.output.results[0].spm_provenance + assert receipt is not None + assert receipt.geography_kind == "national" + + +# A household named by record IDs, as v2 households are: no person is "you". +US_RECORD_HOUSEHOLD_2025 = { + "people": { + "person-1": {"age": {"2025": 35}, "employment_income": {"2025": 30_000}}, + "person-2": {"age": {"2025": 5}}, + }, + "tax_units": {"tax-unit-1": {"members": ["person-1", "person-2"]}}, + "spm_units": {"spm-unit-1": {"members": ["person-1", "person-2"]}}, + "families": {"family-1": {"members": ["person-1", "person-2"]}}, + "marital_units": { + "marital-unit-1": {"members": ["person-1"]}, + "marital-unit-2": {"members": ["person-2"]}, + }, + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "state_name": {"2025": "TX"}, + "county_fips": {"2025": "48201"}, + } + }, +} + + +def _periods(situation: dict) -> set[str]: + """Return every period key of every variable in a situation.""" + return { + str(period) + for plural, entities in situation.items() + if plural != "axes" + for variables in entities.values() + for values in variables.values() + if isinstance(values, dict) + for period in values + } + + +def test_us_calculate_full_calculates_the_request_year_not_this_year() -> None: + # The household is calculated in 2026 (or later), for its 2025 run. + assert datetime.now(UTC).year >= 2026 + + outcome = calculate_household( + _request( + "us", US_RECORD_HOUSEHOLD_2025, year="2025", add_missing_variables=True + ), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + assert _periods(situation) == {"2025"} + tax_unit = situation["tax_units"]["tax-unit-1"] + household = situation["households"]["household-1"] + assert isinstance(tax_unit["income_tax"]["2025"], float) + assert isinstance(household["household_net_income"]["2025"], float) + assert isinstance(situation["people"]["person-1"]["is_blind"]["2025"], bool) + + +def test_us_an_axis_without_a_period_varies_the_request_year() -> None: + # The US model's own default year is 2024; an axis that named no period + # used to vary 2024's income while the household was calculated for 2025. + household = { + **US_RECORD_HOUSEHOLD_2025, + "people": { + "person-1": {"age": {"2025": 35}, "employment_income": {"2025": 0}}, + "person-2": {"age": {"2025": 5}}, + }, + "households": { + "household-1": { + **US_RECORD_HOUSEHOLD_2025["households"]["household-1"], + "household_net_income": {"2025": None}, + } + }, + "axes": [[{"name": "employment_income", "count": 3, "min": 0, "max": 60_000}]], + } + + outcome = calculate_household( + _request("us", household, year="2025"), + worker=_worker("us"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + incomes = situation["households"]["household-1"]["household_net_income"]["2025"] + assert incomes[0] < incomes[1] < incomes[2] + assert situation["people"]["person-1"]["employment_income"] == { + "2025": [0.0, 30_000.0, 60_000.0] + } + + +UK_SINGLE = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}}, + "benunits": {"your benefit unit": {"members": ["you"]}}, + "households": { + "your household": {"members": ["you"], "household_net_income": {"2026": None}} + }, +} + + +def test_uk_axes_calculate_one_value_per_point() -> None: + household = { + **UK_SINGLE, + "axes": [ + [ + { + "name": "employment_income", + "count": 3, + "min": 0, + "max": 60_000, + "period": "2026", + } + ] + ], + } + + outcome = calculate_household( + _request("uk", household), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + situation = outcome.output.results[0].household.to_situation() + incomes = situation["households"]["your household"]["household_net_income"] + assert len(incomes["2026"]) == 3 + assert incomes["2026"][0] < incomes["2026"][2] + assert situation["people"]["you"]["employment_income"] == { + "2026": [0.0, 30_000.0, 60_000.0] + } + assert outcome.output.results[0].spm_provenance is None + assert outcome.output.spm_config is None + + +def test_uk_calculate_full_adds_every_missing_yearly_variable() -> None: + outcome = calculate_household( + _request("uk", UK_SINGLE, add_missing_variables=True), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + you = outcome.output.results[0].household.to_situation()["people"]["you"] + assert isinstance(you["income_tax"]["2026"], float) + + +def test_uk_unrecognized_inputs_fail_with_public_api_errors() -> None: + household = { + **UK_SINGLE, + "people": {"you": {**UK_SINGLE["people"]["you"], "not_a_variable": 1}}, + } + request = _request( + "uk", + household, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": {"gov.not.a.parameter": {}}}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + # The first policy's request already fails on the household's own input, + # as the Public API's baseline request would. + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_inputs" + assert outcome.error.message == ( + "Unrecognized calculate input(s): Unrecognized household variable " + "`not_a_variable` at `household.people.you.not_a_variable`." + ) + assert outcome.error.policy_key is None + assert [error.path for error in outcome.error.input_errors] == [ + "household.people.you.not_a_variable" + ] + + +def test_uk_unrecognized_policy_parameters_name_their_policy() -> None: + request = _request( + "uk", + UK_SINGLE, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "reform", "parameters": {"gov.not.a.parameter": {}}}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_inputs" + assert outcome.error.policy_key == "reform" + assert [error.path for error in outcome.error.input_errors] == [ + "policy.gov.not.a.parameter" + ] + + +def test_uk_reform_values_given_as_text_are_converted_to_numbers() -> None: + # The Public API converts a reform value to the parameter's type, reading + # numeric text as a number first. + basic_rate = "gov.hmrc.income_tax.rates.uk[0].rate" + request = _request( + "uk", + UK_SINGLE, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + { + "policy_key": "reform", + "parameters": {basic_rate: {"2026-01-01.2100-12-31": "0.3"}}, + }, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + baseline, reform = ( + result.household.to_situation()["households"]["your household"][ + "household_net_income" + ]["2026"] + for result in outcome.output.results + ) + assert isinstance(baseline, float) + assert isinstance(reform, float) + assert reform < baseline + + +def test_uk_deprecated_inputs_warn_in_every_policy_result() -> None: + household = { + **UK_SINGLE, + "people": { + "you": { + **UK_SINGLE["people"]["you"], + "uc_has_limited_capability_for_work": {"2026": True}, + } + }, + } + request = _request( + "uk", + household, + policies=[ + {"policy_key": "baseline", "parameters": {}}, + {"policy_key": "other-baseline", "parameters": {}}, + ], + ) + + outcome = calculate_household( + request, + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionResult), outcome + for result in outcome.output.results: + assert len(result.warnings) == 1 + assert "uc_has_limited_capability_for_work" in result.warnings[0] + you = result.household.to_situation()["people"]["you"] + assert "uc_has_limited_capability_for_work" not in you + + +def test_uk_spm_settings_are_refused() -> None: + outcome = calculate_household( + _request("uk", UK_SINGLE, spm={}), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_spm_selection" + assert outcome.error.spm_error is not None + assert outcome.error.spm_error.code == "SPM_SETTINGS_UNSUPPORTED" + + +def test_uk_member_who_is_not_a_person_is_an_invalid_household() -> None: + household = { + **UK_SINGLE, + "benunits": {"your benefit unit": {"members": ["you", "ghost"]}}, + } + + outcome = calculate_household( + _request("uk", household), + worker=_worker("uk"), + dependencies=HouseholdCalculationDependencies(), + ) + + assert isinstance(outcome, HouseholdExecutionFailure) + assert outcome.error.code == "invalid_household" + assert outcome.error.message.startswith("Invalid household payload: ") + + +def test_country_models_are_loaded_once_per_process() -> None: + first = load_country_household_model("uk") + + assert load_country_household_model("uk") is first + assert first.metadata.entities["benunit"].plural == "benunits" + assert "gov.hmrc.income_tax.rates.uk[0].rate" in first.metadata.parameters diff --git a/projects/policyengine-simulation-executor/tests/test_household_call_reports.py b/projects/policyengine-simulation-executor/tests/test_household_call_reports.py new file mode 100644 index 000000000..9eb484a27 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_call_reports.py @@ -0,0 +1,118 @@ +"""A household function's last step reports that its call ended to sim.""" + +from __future__ import annotations + +import sys +from uuid import uuid4 + +import pytest +from test_stage12_modal_app import ( + RecordingRuntime, + _household_payload, + _load, +) + +HOUSEHOLD_CALL_ID = "fc-household" + + +class Outcome: + """A household calculation outcome, as the calculation returns it.""" + + def model_dump(self, *, mode): + """Return the outcome as JSON.""" + assert mode == "json" + return {"status": "succeeded"} + + +def _module_reporting_to(monkeypatch, reports: list, calculate): + """Load the v2 app with a call ID, a report recorder and ``calculate``.""" + from policyengine_simulation_executor import household_calculation + + module = _load(monkeypatch) + monkeypatch.setattr( + module, "init_process_observability", lambda **_: RecordingRuntime() + ) + monkeypatch.setattr( + sys.modules["modal"], + "current_function_call_id", + lambda: HOUSEHOLD_CALL_ID, + raising=False, + ) + monkeypatch.setattr( + module.report_call_ended, "spawn", reports.append, raising=False + ) + monkeypatch.setattr(household_calculation, "calculate_household", calculate) + return module + + +def test_a_household_call_reports_its_runs_when_it_ends(monkeypatch) -> None: + reports: list = [] + module = _module_reporting_to(monkeypatch, reports, lambda *_, **__: Outcome()) + baseline_run, reform_run = str(uuid4()), str(uuid4()) + + result = module.calculate_household_us( + _household_payload("us"), [baseline_run, reform_run] + ) + + assert result == {"status": "succeeded"} + assert reports == [ + { + "modal_call_id": HOUSEHOLD_CALL_ID, + "simulation_run_ids": [baseline_run, reform_run], + "report_run_id": None, + } + ] + + +def test_a_household_call_that_raises_still_reports(monkeypatch) -> None: + reports: list = [] + + def fail(*_, **__): + msg = "the country model could not load" + raise RuntimeError(msg) + + module = _module_reporting_to(monkeypatch, reports, fail) + run_id = str(uuid4()) + + with pytest.raises(RuntimeError, match="could not load"): + module.calculate_household_uk(_household_payload("uk"), [run_id]) + + assert [report["simulation_run_ids"] for report in reports] == [[run_id]] + + +def test_a_pre_empted_household_call_reports_nothing(monkeypatch) -> None: + """Modal re-runs a pre-empted call under the same ID; the re-run reports.""" + reports: list = [] + + def pre_empt(*_, **__): + raise KeyboardInterrupt + + module = _module_reporting_to(monkeypatch, reports, pre_empt) + + with pytest.raises(KeyboardInterrupt): + module.calculate_household_us(_household_payload("us"), [str(uuid4())]) + + assert reports == [] + + +def test_a_household_call_without_runs_reports_nothing(monkeypatch) -> None: + """Release validation and the image check call the functions without runs.""" + reports: list = [] + module = _module_reporting_to(monkeypatch, reports, lambda *_, **__: Outcome()) + + assert module.calculate_household_us(_household_payload("us")) == { + "status": "succeeded" + } + assert reports == [] + + +def test_run_ids_that_are_not_uuids_report_nothing_but_still_calculate( + monkeypatch, +) -> None: + reports: list = [] + module = _module_reporting_to(monkeypatch, reports, lambda *_, **__: Outcome()) + + result = module.calculate_household_us(_household_payload("us"), ["not-a-run"]) + + assert result == {"status": "succeeded"} + assert reports == [] diff --git a/projects/policyengine-simulation-executor/tests/test_household_inputs.py b/projects/policyengine-simulation-executor/tests/test_household_inputs.py new file mode 100644 index 000000000..9da89a7f3 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_inputs.py @@ -0,0 +1,379 @@ +"""Tests for household input preparation, ported from the Public API.""" + +from __future__ import annotations + +from datetime import date +from types import SimpleNamespace + +from policyengine_simulation_executor.household_inputs import ( + HouseholdEntity, + HouseholdModelMetadata, + HouseholdVariable, + add_missing_variables, + assign_request_year, + drop_deprecated_inputs, + find_unrecognized_household_inputs, + find_unrecognized_policy_inputs, + requested_computations, + unrecognized_inputs_message, +) + + +def _metadata() -> HouseholdModelMetadata: + variables = [ + HouseholdVariable("age", "person", "year", is_input=True, default_value=40), + HouseholdVariable( + "employment_income", "person", "year", is_input=True, default_value=0 + ), + HouseholdVariable( + "is_blind", "person", "eternity", is_input=True, default_value=False + ), + HouseholdVariable( + "monthly_rent", "household", "month", is_input=True, default_value=0 + ), + HouseholdVariable( + "state_name", "household", "year", is_input=True, default_value="CA" + ), + HouseholdVariable( + "household_net_income", + "household", + "year", + is_input=False, + default_value=0, + ), + HouseholdVariable( + "daily_amount", "household", "day", is_input=True, default_value=0 + ), + ] + return HouseholdModelMetadata( + variables={variable.name: variable for variable in variables}, + entities={ + "person": HouseholdEntity("person", "people", frozenset()), + "household": HouseholdEntity( + "household", "households", frozenset({"members"}) + ), + }, + parameters=frozenset({"gov.tax.rate", "gov.tax.brackets[0].rate"}), + ) + + +def _situation() -> dict: + # People are named by record ID, as v2 households name them. + return { + "people": {"person-1": {"age": {"2025": 30}}, "person-2": {}}, + "households": {"household-1": {"members": ["person-1", "person-2"]}}, + } + + +def test_add_missing_variables_adds_them_for_the_request_year() -> None: + situation = _situation() + + add_missing_variables(situation, _metadata(), year="2025") + + assert situation == { + "people": { + "person-1": { + "age": {"2025": 30}, + "employment_income": {"2025": 0}, + "is_blind": {"2025": False}, + }, + "person-2": { + "age": {"2025": 40}, + "employment_income": {"2025": 0}, + "is_blind": {"2025": False}, + }, + }, + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "monthly_rent": {"2025": 0}, + "state_name": {"2025": "CA"}, + # Calculated variables are requested, never defaulted. + "household_net_income": {"2025": None}, + } + }, + } + + +def test_the_year_never_comes_from_a_person_named_you() -> None: + # The Public API takes the first period of the age of a person named + # "you"; a v2 request names its year instead. + situation = { + "people": {"you": {"age": {"2030": 30}}}, + "households": {"home": {"members": ["you"]}}, + } + + add_missing_variables(situation, _metadata(), year="2025") + + assert situation["people"]["you"]["employment_income"] == {"2025": 0} + assert situation["households"]["home"]["household_net_income"] == {"2025": None} + + +def test_axes_and_values_without_a_period_are_for_the_request_year() -> None: + # The country model would put them in its own default year (2024 for the + # US, 2025 for the UK), not the year the request calculates. + situation = { + "people": { + "person-1": {"age": 30, "employment_income": {"2024": 1}}, + "person-2": {"is_blind": None}, + }, + "households": { + "household-1": {"members": ["person-1", "person-2"], "state_name": "TX"} + }, + "axes": [ + [ + {"name": "employment_income", "count": 2, "min": 0, "max": 1}, + {"name": "age", "count": 2, "min": 0, "max": 1, "period": "2024"}, + ], + [{"name": "rent", "count": 2, "min": 0, "max": 1, "period": None}], + ], + } + + assign_request_year(situation, year="2025") + + assert situation == { + "people": { + "person-1": {"age": {"2025": 30}, "employment_income": {"2024": 1}}, + "person-2": {"is_blind": {"2025": None}}, + }, + # The membership list stays a list beside the group's own value. + "households": { + "household-1": { + "members": ["person-1", "person-2"], + "state_name": {"2025": "TX"}, + } + }, + "axes": [ + [ + { + "name": "employment_income", + "count": 2, + "min": 0, + "max": 1, + "period": "2025", + }, + {"name": "age", "count": 2, "min": 0, "max": 1, "period": "2024"}, + ], + [{"name": "rent", "count": 2, "min": 0, "max": 1, "period": "2025"}], + ], + } + + +def test_deprecated_inputs_are_dropped_with_public_api_warnings() -> None: + situation = { + "people": {"you": {"age": {"2026": 30}, "medical_out_of_pocket_expenses": 5}}, + "axes": [ + [{"name": "uc_has_limited_capability_for_work", "count": 2}], + [{"name": "employment_income", "count": 2}], + ], + } + + result = drop_deprecated_inputs(situation) + + assert result.household == { + "people": {"you": {"age": {"2026": 30}}}, + "axes": [[{"name": "employment_income", "count": 2}]], + } + assert "medical_out_of_pocket_expenses" in situation["people"]["you"] + assert [warning.message for warning in result.warnings] == [ + "Input `medical_out_of_pocket_expenses` on `people/you` is deprecated and " + "was ignored for this calculation. Removed in policyengine-us 1.673.0. " + "Migrate non-premium spending to `other_medical_expenses` and premium " + "spending to `health_insurance_premiums`.", + "Input `uc_has_limited_capability_for_work` on `axes[0][0].name` is " + "deprecated and was ignored for this calculation. Removed in " + "policyengine-uk 2.129.0. Set `uc_limited_capability_for_work` instead.", + ] + + +def test_axes_left_empty_by_deprecated_inputs_are_removed() -> None: + situation = { + "people": {"you": {}}, + "axes": [[{"name": "medical_out_of_pocket_expenses", "count": 2}]], + } + + assert "axes" not in drop_deprecated_inputs(situation).household + + +def test_unrecognized_household_inputs_use_public_api_types_and_messages() -> None: + situation = { + "people": { + "you": {"age": {"2026": 30}, "not_a_variable": 1, "state_name": "CA"} + }, + "households": {"home": {"members": ["you"]}}, + "pets": {"rex": {}}, + "axes": [[{"name": "unknown_axis", "count": 2}]], + } + + errors = find_unrecognized_household_inputs(situation, _metadata()) + + assert [error.model_dump(mode="json") for error in errors] == [ + { + "type": "household_variable", + "name": "not_a_variable", + "path": "household.people.you.not_a_variable", + "message": ( + "Unrecognized household variable `not_a_variable` at " + "`household.people.you.not_a_variable`." + ), + }, + { + "type": "household_variable_wrong_entity", + "name": "state_name", + "path": "household.people.you.state_name", + "message": ( + "Household variable `state_name` belongs on `households`, not " + "`people`, at `household.people.you.state_name`." + ), + "expected_entity_plural": "households", + "actual_entity_plural": "people", + }, + { + "type": "household_entity", + "name": "pets", + "path": "household.pets", + "message": ( + "Unrecognized household entity group `pets` at `household.pets`." + ), + }, + { + "type": "household_axis_variable", + "name": "unknown_axis", + "path": "household.axes[0][0].name", + "message": ( + "Unrecognized household axis variable `unknown_axis` at " + "`household.axes[0][0].name`." + ), + }, + ] + assert unrecognized_inputs_message(errors[:1]) == ( + "Unrecognized calculate input(s): Unrecognized household variable " + "`not_a_variable` at `household.people.you.not_a_variable`." + ) + + +def test_unrecognized_policy_parameters_are_reported_by_path() -> None: + errors = find_unrecognized_policy_inputs( + {"gov.tax.rate": {}, "gov.tax.brackets[0].rate": {}, "gov.missing": {}}, + _metadata(), + ) + + assert [error.model_dump(mode="json") for error in errors] == [ + { + "type": "policy_parameter", + "name": "gov.missing", + "path": "policy.gov.missing", + "message": "Unrecognized policy parameter `gov.missing` at " + "`policy.gov.missing`.", + } + ] + + +def test_deprecated_inputs_leave_the_callers_household_unchanged() -> None: + situation = {"people": {"you": {"medical_out_of_pocket_expenses": 5}}} + + result = drop_deprecated_inputs(situation) + + assert result.household == {"people": {"you": {}}} + assert situation == {"people": {"you": {"medical_out_of_pocket_expenses": 5}}} + + +def test_a_household_without_deprecated_inputs_keeps_its_axes() -> None: + situation = { + "people": {"you": {}}, + "axes": [[{"name": "employment_income", "count": 2}]], + } + + result = drop_deprecated_inputs(situation) + + assert result.household == situation + assert result.warnings == () + + +def test_requested_computations_are_the_null_values_without_axes() -> None: + situation = { + "people": {"you": {"age": {"2026": 30}, "employment_income": {"2026": None}}}, + "households": { + "home": {"members": ["you"], "household_net_income": {"2026": None}} + }, + } + + assert requested_computations(situation, include_provided_values=False) == [ + ("people", "you", "employment_income", "2026"), + ("households", "home", "household_net_income", "2026"), + ] + + +def test_requested_computations_with_axes_cover_every_known_variable() -> None: + situation = { + "people": {"you": {"age": {"2026": 30}, "unknown": {"2026": 1}}}, + "households": {"home": {"members": ["you"]}}, + "axes": [[{"name": "age", "count": 2, "min": 0, "max": 1}]], + } + + assert requested_computations( + situation, + include_provided_values=True, + variable_names={"age", "employment_income"}, + ) == [("people", "you", "age", "2026")] + + +def test_metadata_is_read_from_a_tax_benefit_system() -> None: + class Enum: + pass + + class Level: + name = "LOW" + + person = SimpleNamespace(key="person", plural="people", is_person=True) + household = SimpleNamespace( + key="household", + plural="households", + is_person=False, + roles=[SimpleNamespace(plural="members"), SimpleNamespace(plural=None)], + ) + + def variable(name, entity, value_type, default_value, *, is_input=True): + return SimpleNamespace( + entity=entity, + definition_period="year", + value_type=value_type, + default_value=default_value, + is_input_variable=lambda: is_input, + ) + + parameters = SimpleNamespace( + get_descendants=lambda: [ + SimpleNamespace(name="gov.tax.rate"), + SimpleNamespace(name="calibration.factor"), + ] + ) + system = SimpleNamespace( + entities=[person, household], + variables={ + "age": variable("age", person, int, 40), + "level": variable("level", household, Enum, Level()), + "label": variable("label", household, str, "x"), + "start": variable("start", person, date, date(2020, 1, 1)), + "net_income": variable("net_income", household, float, 0.0, is_input=False), + }, + parameters=parameters, + ) + + metadata = HouseholdModelMetadata.from_tax_benefit_system(system) + + assert metadata.entities["household"] == HouseholdEntity( + "household", "households", frozenset({"members"}) + ) + assert metadata.entities["person"].role_plurals == frozenset() + assert [ + (item.name, item.entity, item.default_value, item.is_input) + for item in metadata.variables.values() + ] == [ + ("age", "person", 40, True), + ("level", "household", "LOW", True), + ("label", "household", None, True), + ("start", "person", None, True), + ("net_income", "household", 0.0, False), + ] + assert metadata.parameters == frozenset({"gov.tax.rate"}) diff --git a/projects/policyengine-simulation-executor/tests/test_household_spm.py b/projects/policyengine-simulation-executor/tests/test_household_spm.py new file mode 100644 index 000000000..d1053aa52 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_spm.py @@ -0,0 +1,406 @@ +"""Tests for household SPM selection and receipts, ported from the Public API.""" + +from __future__ import annotations + +import pytest +from policyengine_simulation_contract.spm import SPMInputError +from policyengine_simulation_executor import household_spm + +FORECAST_SHA = "3d86d5c4c0423480e6b69b75d222ffa4a7a2639e4094df5ba2504af01be17173" +DEFAULTS = { + "as_of": None, + "county_vintage": "2020", + "forecast_content_sha256": FORECAST_SHA, + "geography_id": None, + "geography_kind": "county", + "scenario": "ce_trend", +} +RUNTIME_VERSIONS = { + "policyengine": "6.7.5", + "policyengine-core": "3.33.0", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", +} + + +class FakeForecast: + years = (2025, 2026) + + def __init__(self, *, metro_areas=("31080",)): + self.metro_areas = metro_areas + + def entry(self, year, *, scenario, as_of): + if scenario not in {"ce_trend", "flat"}: + msg = f"Unknown forecast scenario: {scenario}" + raise ValueError(msg) + + def geography_factor(self, year, tenure, **options): + if options["geoid"] not in self.metro_areas: + msg = f"unknown area {options['geoid']}" + raise ValueError(msg) + return 1.0 + + +@pytest.fixture +def certified_bundle(monkeypatch): + monkeypatch.setattr( + household_spm, "_bundle_spm_configuration", lambda: dict(DEFAULTS) + ) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + monkeypatch.setattr(household_spm, "certified_forecast", lambda sha: FakeForecast()) + + +# Stands for a request that sends no SPM settings at all. +OMITTED = object() + + +def _resolve(selection=OMITTED, *, country="us", saved_household=False): + return household_spm.resolve_household_spm_selection( + country, + None if selection is OMITTED else selection, + sent=selection is not OMITTED, + saved_household=saved_household, + ) + + +def _error(selection=OMITTED, **options) -> SPMInputError: + with pytest.raises(SPMInputError) as raised: + _resolve(selection, **options) + return raised.value + + +def test_omitted_selection_resolves_the_certified_defaults(certified_bundle) -> None: + assert _resolve() == DEFAULTS + + +def test_explicit_choices_override_defaults(certified_bundle) -> None: + assert _resolve({"geography_kind": "metro", "geography_id": "31080"}) == { + **DEFAULTS, + "geography_kind": "metro", + "geography_id": "31080", + } + assert _resolve({"scenario": "flat"})["scenario"] == "flat" + + +def test_other_countries_accept_no_selection(certified_bundle) -> None: + assert _resolve(country="uk") is None + error = _error({}, country="uk") + assert (error.code, error.message) == ( + "SPM_SETTINGS_UNSUPPORTED", + "SPM settings are only available for the US", + ) + + +def test_an_explicit_null_is_invalid_for_the_us_and_unsupported_elsewhere( + certified_bundle, +) -> None: + us_error = _error(None) + uk_error = _error(None, country="uk") + + assert (us_error.code, us_error.message) == ( + "SPM_SETTINGS_INVALID", + "spm must be an object; omit it to inherit the certified defaults.", + ) + assert (uk_error.code, uk_error.message) == ( + "SPM_SETTINGS_UNSUPPORTED", + "SPM settings are only available for the US", + ) + + +@pytest.mark.parametrize( + ("selection", "message"), + [ + ({"geography_kind": "metro"}, "An SPM area selection requires geography_id"), + ({"geography_id": "31080"}, "Only an SPM area selection accepts geography_id"), + ("national", "Input should be a valid dictionary or instance of SPMSelection"), + ], +) +def test_a_malformed_selection_is_invalid_before_the_bundle_is_read( + monkeypatch, selection, message +) -> None: + # As in the Public API, even a bundle without SPM support reports the + # settings' own problem first. + monkeypatch.setattr(household_spm, "_bundle_spm_configuration", lambda: None) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: False) + + error = _error(selection) + + assert (error.code, error.message) == ("SPM_SETTINGS_INVALID", message) + + +def test_a_foreign_artifact_hash_is_reported_by_where_it_came_from( + certified_bundle, +) -> None: + selection = {"forecast_content_sha256": "f" * 64} + + requested = _error(selection) + saved = _error(selection, saved_household=True) + + assert (requested.code, requested.message) == ( + "SPM_SETTINGS_INVALID", + "SPM selection does not match this bundle's artifact hash", + ) + assert saved.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert "saved selection" in saved.message + + +@pytest.mark.parametrize( + ("selection", "code", "message"), + [ + ( + {"scenario": "unknown"}, + "SPM_SETTINGS_INVALID", + "Unknown forecast scenario: unknown", + ), + ( + {"county_vintage": "2010"}, + "SPM_SETTINGS_INVALID", + "Unsupported county vintage: use 2020", + ), + ( + {"geography_kind": "metro", "geography_id": "99999"}, + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM area is unavailable in the selected artifact: 99999", + ), + ], +) +def test_invalid_selections_use_public_api_codes( + certified_bundle, selection, code, message +) -> None: + error = _error(selection) + + assert (error.code, error.message) == (code, message) + + +def test_a_bundle_without_spm_keeps_legacy_behaviour_only_for_old_models( + monkeypatch, +) -> None: + monkeypatch.setattr(household_spm, "_bundle_spm_configuration", lambda: None) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: False) + + assert _resolve() is None + assert _error({}).code == "SPM_SETTINGS_UNSUPPORTED" + + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + assert _error().code == "SPM_CONFIGURATION_UNAVAILABLE" + + +def test_an_unreadable_bundle_manifest_fails_closed(monkeypatch) -> None: + def unreadable_bundle(): + msg = "bundle manifest is missing" + raise ValueError(msg) + + monkeypatch.setattr(household_spm, "_current_bundle", unreadable_bundle) + + error = _error() + + assert (error.code, error.message) == ( + "SPM_CONFIGURATION_UNAVAILABLE", + "The installed bundle manifest is invalid", + ) + + +def test_changing_the_geography_kind_drops_an_inherited_area(monkeypatch) -> None: + monkeypatch.setattr( + household_spm, + "_bundle_spm_configuration", + lambda: {**DEFAULTS, "geography_kind": "metro", "geography_id": "31080"}, + ) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + monkeypatch.setattr(household_spm, "certified_forecast", lambda sha: FakeForecast()) + + assert _resolve({"geography_kind": "national"}) == { + **DEFAULTS, + "geography_kind": "national", + } + assert _resolve()["geography_id"] == "31080" + + +def test_an_uncertifiable_bundle_configuration_fails_closed(monkeypatch) -> None: + monkeypatch.setattr( + household_spm, + "_bundle_spm_configuration", + lambda: {**DEFAULTS, "scenario": None}, + ) + monkeypatch.setattr(household_spm, "_us_model_supports_spm", lambda: True) + + assert _error().code == "SPM_CONFIGURATION_UNAVAILABLE" + + +class FakeSimulation: + def __init__(self, *, config=None, receipt=None): + self.spm_config = dict(DEFAULTS) if config is None else config + self.receipt = receipt + + def spm_provenance(self): + return self.receipt + + +def _country_receipt(**overrides) -> dict: + return { + "forecast_id": "spm-forecast-2026", + "forecast_sha256": FORECAST_SHA, + "scenario": "ce_trend", + "geography_kind": "county", + "years": {"2027": {}, "2026": {}}, + "runtime_versions": RUNTIME_VERSIONS, + "composition_method": "ignored by the compact receipt", + **overrides, + } + + +def test_the_country_receipt_becomes_the_compact_public_receipt() -> None: + provenance = household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt()), DEFAULTS + ) + + assert provenance is not None + assert provenance.model_dump(mode="json", by_alias=True) == { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "spm-forecast-2026", + "forecast_sha256": FORECAST_SHA, + "scenario": "ce_trend", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2026", "2027"], + "runtime_versions": RUNTIME_VERSIONS, + } + + +def test_a_country_without_spm_has_no_receipt() -> None: + assert household_spm.household_spm_provenance(object(), None) is None + + +def test_an_uncertifiable_country_receipt_is_an_spm_error() -> None: + with pytest.raises(SPMInputError) as raised: + household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt(scenario="flat")), DEFAULTS + ) + + assert raised.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert raised.value.message.startswith( + "The installed country model reported an SPM receipt this API cannot certify" + ) + + +_UNCERTIFIABLE = ( + "The installed country model reported an SPM receipt this API cannot certify: " +) + + +def _receipt_without(*names: str, **overrides: object) -> dict: + receipt = _country_receipt(**overrides) + for name in names: + del receipt[name] + return receipt + + +@pytest.mark.parametrize( + ("receipt", "reason"), + [ + # Each required text is checked before the receipt is compared. + ( + _receipt_without("scenario", forecast_sha256="b" * 64), + "Country SPM receipt requires scenario", + ), + ( + _receipt_without("geography_kind", scenario="flat"), + "Country SPM receipt requires geography_kind", + ), + ( + _country_receipt(years=["2026"]), + "Country SPM receipt requires a years mapping", + ), + ( + _country_receipt(years={2026: {}}), + "Country SPM receipt years must be strings", + ), + # Runtime versions are checked before the forecast ID. + ( + _receipt_without("runtime_versions", "forecast_id"), + "Country SPM receipt requires runtime_versions", + ), + ( + _receipt_without("forecast_id"), + "Country SPM receipt requires forecast_id", + ), + ( + _country_receipt(years={"26": {}}), + "years: years must contain four-digit calendar years", + ), + ], +) +def test_uncertifiable_receipts_are_worded_as_the_public_api_words_them( + receipt, reason +) -> None: + with pytest.raises(SPMInputError) as raised: + household_spm.household_spm_provenance( + FakeSimulation(receipt=receipt), DEFAULTS + ) + + assert raised.value.code == "SPM_CONFIGURATION_UNAVAILABLE" + assert raised.value.message == _UNCERTIFIABLE + reason + + +def test_a_receipt_that_is_not_an_object_is_uncertifiable() -> None: + with pytest.raises(SPMInputError) as raised: + household_spm.household_spm_provenance( + FakeSimulation(receipt=["not", "an", "object"]), DEFAULTS + ) + + assert ( + raised.value.message == _UNCERTIFIABLE + "Country SPM receipt must be an object" + ) + + +def test_a_receipt_for_another_selection_is_rejected() -> None: + with pytest.raises(ValueError, match="differs from the request"): + household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt()), + {**DEFAULTS, "county_vintage": "2010"}, + ) + with pytest.raises(ValueError, match="without SPM"): + household_spm.household_spm_provenance( + FakeSimulation(receipt=_country_receipt()), None + ) + + +class CountryInputError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def test_spm_errors_are_recognized_through_wrapping_exceptions() -> None: + cause = CountryInputError("SPM_GEOGRAPHY_REQUIRED", "needs a county") + wrapped = RuntimeError("while calculating spm_unit_spm_threshold") + wrapped.__cause__ = cause + + detail = household_spm.household_spm_error_detail(wrapped) + + assert detail is not None + assert (detail.code, detail.message) == ("SPM_GEOGRAPHY_REQUIRED", "needs a county") + assert ( + household_spm.household_spm_error_detail( + SPMInputError("SPM_SETTINGS_INVALID", "bad") + ) + is not None + ) + assert ( + household_spm.household_spm_error_detail( + CountryInputError("SPM_SCENARIO_UNAVAILABLE", "no scenario") + ) + is None + ) + assert household_spm.household_spm_error_detail(ValueError("plain")) is None + + +def test_the_certified_forecast_is_loaded_once_by_its_digest() -> None: + # The household path keeps its own loader, separate from the main path's + # SPM module. + forecast = household_spm.certified_forecast(FORECAST_SHA) + + assert household_spm.certified_forecast(FORECAST_SHA) is forecast + assert forecast.years diff --git a/projects/policyengine-simulation-executor/tests/test_household_validation.py b/projects/policyengine-simulation-executor/tests/test_household_validation.py new file mode 100644 index 000000000..f0e289d8e --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_household_validation.py @@ -0,0 +1,138 @@ +"""Tests for the household that checks a v2 household function calculates.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, + HouseholdWorkerIdentity, +) +from policyengine_simulation_executor.household_calculation import ( + calculate_household, + installed_household_bundle, +) +from policyengine_simulation_executor.household_validation import ( + check_household_validation, + household_validation_request, +) + +WORKER = HouseholdWorkerIdentity( + application_name="policyengine-simulation-v2-py-test", + function_name="calculate_household_uk", +) + + +def _succeeded(request: HouseholdExecutionRequest) -> dict: + situation = request.household.to_situation() + situation["households"]["your household"]["household_net_income"] = { + "2026": 25_000.0 + } + return { + "status": "succeeded", + "schema_version": 1, + "calculation_id": str(request.calculation_id), + "output": { + "country": request.country, + "bundle": request.bundle.model_dump(mode="json"), + "results": [{"policy_key": "baseline", "household": situation}], + }, + "worker": WORKER.model_dump(mode="json"), + } + + +@pytest.mark.parametrize( + ("country", "group_plural"), [("us", "spm_units"), ("uk", "benunits")] +) +def test_the_validation_household_is_one_adult_under_current_law( + country, group_plural +) -> None: + request = household_validation_request(country, year=2026) + + situation = request.household.to_situation() + assert request.country == country + assert request.year == "2026" + assert request.bundle == installed_household_bundle(country) + assert [policy.parameters for policy in request.policies] == [{}] + assert list(situation["people"]) == ["you"] + assert situation[group_plural] == {"your unit": {"members": ["you"]}} + assert situation["households"]["your household"]["household_net_income"] == { + "2026": None + } + + +def test_a_calculated_net_income_passes_the_check() -> None: + request = household_validation_request("uk", year=2026) + + net_income = check_household_validation( + _succeeded(request), request=request, worker=WORKER + ) + + assert net_income == 25_000.0 + + +def _failed(outcome: dict) -> dict: + return { + "status": "failed", + "schema_version": 1, + "calculation_id": outcome["calculation_id"], + "error": {"code": "calculation_failed", "message": "model exploded"}, + "worker": outcome["worker"], + } + + +def _with_another_bundle(outcome: dict) -> dict: + changed = deepcopy(outcome) + changed["output"]["bundle"]["policyengine_version"] = "0.0.1" + return changed + + +def _from_another_function(outcome: dict) -> dict: + changed = deepcopy(outcome) + changed["worker"]["function_name"] = "calculate_household_us" + return changed + + +def _without_net_income(outcome: dict) -> dict: + changed = deepcopy(outcome) + household = changed["output"]["results"][0]["household"] + household["households"]["your household"]["household_net_income"] = {"2026": None} + return changed + + +@pytest.mark.parametrize( + ("change_outcome", "message"), + [ + (_failed, "did not calculate the validation household"), + (_with_another_bundle, "calculated with another bundle"), + (_from_another_function, "reported another function"), + (_without_net_income, "returned no household net income"), + ], +) +def test_an_outcome_that_is_not_the_calculated_household_fails_the_check( + change_outcome, message +) -> None: + request = household_validation_request("uk", year=2026) + + with pytest.raises(RuntimeError, match=f"calculate_household_uk {message}"): + check_household_validation( + change_outcome(_succeeded(request)), request=request, worker=WORKER + ) + + +@pytest.mark.parametrize("country", ["us", "uk"]) +def test_the_installed_models_calculate_the_validation_household(country) -> None: + # Release validation and the image check depend on this household, so the + # PR checks calculate it with the installed country models too. + request = household_validation_request(country, year=2026) + worker = WORKER.model_copy( + update={"function_name": f"calculate_household_{country}"} + ) + + outcome = calculate_household(request, worker=worker, required_country=country) + + net_income = check_household_validation( + outcome.model_dump(mode="json"), request=request, worker=worker + ) + assert net_income > 0 diff --git a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py index d427800d9..b29699da8 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py @@ -182,7 +182,15 @@ def test_syncs_expected_hf_key_before_image_smokes(self, tmp_path): "run --frozen modal secret create pe-uk-private-hf-read-token " f"HUGGING_FACE_TOKEN={FAKE_HF_TOKEN} --env=staging --force" ) - assert len(calls) == 4 + assert calls[1:] == [ + "run --frozen modal run --env=staging " + "src/policyengine_simulation_gateway/smoke_app.py", + "run --frozen modal run --env=staging src/modal/smoke_app.py", + "run --frozen modal run --env=staging " + "src/modal/v2_static_runtime_files_smoke.py", + # Builds every v2 image and imports its entry points. + "run --frozen modal run --env=staging src/modal/v2_image_smoke.py", + ] def test_workflow_supplies_hf_token(self): """CI must validate the GitHub value before synchronizing it to Modal.""" diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index ccf4a1918..ab3d4d11d 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -326,6 +326,9 @@ def test_v2_app_declares_validation_workers_and_non_http_coordinator( "run_single_simulation_us", "run_single_simulation_uk", "coordinate_report", + "report_call_ended", + "calculate_household_us", + "calculate_household_uk", } assert functions["run_single_simulation_us"]["image"] is module.us_worker_image assert functions["run_single_simulation_uk"]["image"] is module.uk_worker_image @@ -398,6 +401,119 @@ def fail(*_, **__): assert runtime.shutdown_calls == 1 +def test_household_functions_run_without_datasets_or_secrets(monkeypatch) -> None: + module = _load(monkeypatch) + functions = dict(module.app.function_calls) + image_calls = module.household_image.calls + + for country in ("us", "uk"): + declaration = functions[f"calculate_household_{country}"] + assert declaration["image"] is module.household_image + assert declaration["secrets"] == [] + assert declaration["cpu"] == 2.0 + # 4 GiB reserved, up to 16 GiB, for large households with axes. + assert declaration["memory"] == (4096, 16384) + assert declaration["timeout"] == 900 + assert declaration["retries"] == 0 + assert declaration["max_containers"] == 20 + # The image installs the locked packages and the source, but downloads no + # dataset or cache, so building it needs no secret either. + assert [call[0] for call in image_calls[:4]] == [ + "debian_slim", + "apt_install", + "env", + "uv_sync", + ] + assert not any(call[0] in {"run_commands", "run_function"} for call in image_calls) + assert not any( + isinstance(argument, dict) and "secrets" in argument + for call in image_calls + for argument in call[1:] + ) + environment = { + name: value + for call in image_calls + if call[0] == "env" + for name, value in call[1].items() + } + assert "POLICYENGINE_DATA_FOLDER" not in environment + assert "STAGE12_CACHE_MANIFEST_DIGEST" not in environment + assert "OTEL_TRACES_EXPORTER" in environment + local_source = next( + call for call in image_calls if call[0] == "add_local_python_source" + ) + assert "policyengine_simulation_executor" in local_source[1] + + +def _household_payload(country: str) -> dict: + return { + "calculation_id": "00000000-0000-4000-8000-0000000000c1", + "country": country, + "year": "2026", + "household": {"people": {"you": {"age": {"2026": 40}}}}, + "policies": [{"policy_key": "baseline", "parameters": {}}], + "bundle": { + "policyengine_version": "6.7.5", + "core_package_version": "3.0.0", + "country_package_name": f"policyengine-{country}", + "country_package_version": "1.0.0", + "bundle_manifest_sha256": "a" * 64, + }, + } + + +@pytest.mark.parametrize("country", ["us", "uk"]) +def test_household_functions_calculate_their_own_country_with_telemetry( + monkeypatch, country +) -> None: + from policyengine_simulation_executor import household_calculation + + module = _load(monkeypatch) + runtime = RecordingRuntime() + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + calls: list[dict] = [] + + class Outcome: + def model_dump(self, *, mode): + assert mode == "json" + return {"status": "succeeded"} + + def calculate(request, **keywords): + calls.append({"request": request, **keywords}) + return Outcome() + + monkeypatch.setattr(household_calculation, "calculate_household", calculate) + + result = getattr(module, f"calculate_household_{country}")( + _household_payload(country), + observability_context=_observability_context(), + ) + + assert result == {"status": "succeeded"} + (call,) = calls + assert call["request"].country == country + assert call["required_country"] == country + assert call["worker"].application_name == module.APP_NAME + assert call["worker"].function_name == f"calculate_household_{country}" + assert call["dependencies"].runtime is runtime + (operation,) = runtime.operations + assert operation["name"] == "v2_household_execution" + assert operation["attributes"] == {"country": country} + assert operation["remote_context"]["observability_id"] == OBSERVABILITY_ID + assert runtime.shutdown_calls == 1 + + +def test_household_functions_close_runtime_after_an_error(monkeypatch) -> None: + module = _load(monkeypatch) + runtime = RecordingRuntime() + monkeypatch.setattr(module, "init_process_observability", lambda **_: runtime) + + with pytest.raises(ValueError, match="validation error"): + module.calculate_household_us({"country": "us"}) + + assert runtime.shutdown_calls == 1 + + def test_external_package_values_are_assertions_only(monkeypatch) -> None: install_fake_modal(monkeypatch) monkeypatch.setenv("STAGE12_EXPECT_US_VERSION", "not-the-bundled-version") diff --git a/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py b/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py index a808efcbe..f37e3697b 100644 --- a/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py +++ b/projects/policyengine-simulation-executor/tests/test_update_v2_version_manifest.py @@ -38,6 +38,36 @@ def test_publisher_uses_only_packaged_bundle_values() -> None: ) +def test_publisher_records_every_bundle_country_as_economy_capable() -> None: + worker = publisher.build_worker(_validation().model_dump(mode="json")) + + assert [ + ( + country.country, + country.economy_capable, + country.single_simulation_callable, + country.household_callable, + ) + for country in worker.countries + ] == [ + (country.country, True, f"run_single_simulation_{country.country}", None) + for country in publisher.load_stage12_bundle().bundle.countries + ] + + +def test_publisher_records_each_validated_household_function() -> None: + payload = _validation().model_dump(mode="json") + payload["household_validation_invocation_ids"] = {"uk": "household-uk-1"} + + worker = publisher.build_worker(payload) + + assert { + country.country: country.household_callable for country in worker.countries + } == {"us": None, "uk": "calculate_household_uk"} + assert worker.household_worker("uk") is not None + assert worker.household_worker("us") is None + + def test_publisher_rejects_validation_for_another_application() -> None: payload = _validation().model_dump(mode="json") payload["application_name"] = "policyengine-simulation-v2-py-another" diff --git a/projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py b/projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py new file mode 100644 index 000000000..56341d053 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_v2_image_smoke.py @@ -0,0 +1,129 @@ +"""Tests for the pre-merge smoke of every v2 Modal image.""" + +from __future__ import annotations + +import importlib +import sys + +import pytest +from fixtures.fake_modal import install_fake_modal +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionResult, +) + + +@pytest.fixture +def v2_modules(monkeypatch): + """Import the v2 application and its image smoke under the fake Modal.""" + install_fake_modal(monkeypatch) + sys.modules.pop("src.modal.v2_app", None) + sys.modules.pop("src.modal.v2_image_smoke", None) + v2_app = importlib.import_module("src.modal.v2_app") + return v2_app, importlib.import_module("src.modal.v2_image_smoke") + + +def test_the_smoke_builds_every_v2_image(v2_modules) -> None: + v2_app, smoke = v2_modules + functions = dict(smoke.app.function_calls) + + assert functions["smoke_v2_coordinator_image"]["image"] is v2_app.coordinator_image + assert functions["smoke_v2_uk_worker_image"]["image"] is v2_app.uk_worker_image + assert functions["smoke_v2_household_image"]["image"] is v2_app.household_image + # The US worker image without its last data layer, the Stage 12 cache, + # which only a deployment can fetch: the deployment's precompute writes it + # with the deployment's Cloud Storage credential. + us_image = functions["smoke_v2_us_worker_image"]["image"] + data_install = next(call for call in us_image.calls if call[0] == "run_commands") + assert "--country us" in data_install[1][0] + assert "--country uk" not in data_install[1][0] + assert not any(call[0] == "run_function" for call in us_image.calls) + assert [call[0] for call in us_image.calls] == [ + call[0] for call in v2_app.us_worker_image.calls if call[0] != "run_function" + ] + assert all( + declaration.get("secrets") in (None, []) for declaration in functions.values() + ) + + +@pytest.mark.parametrize( + "function_name", + [ + "smoke_v2_coordinator_image", + "smoke_v2_uk_worker_image", + "smoke_v2_us_worker_image", + ], +) +def test_report_image_smokes_import_the_v2_entry_points( + v2_modules, function_name +) -> None: + _, smoke = v2_modules + + report = getattr(smoke, function_name)() + + assert report["modules"] == list(smoke.REPORT_ENTRY_POINT_MODULES) + assert { + "src.modal.v2_app", + "policyengine_simulation_executor.stage12_runtime", + "policyengine_simulation_executor.stage12_worker_validation", + # Imported only while handling a request. + "policyengine_simulation_executor.simulation_runtime", + "microdf", + } <= set(report["modules"]) + assert report["policyengine_version"] + + +def test_household_image_smoke_calculates_each_validation_household( + monkeypatch, v2_modules +) -> None: + from policyengine_simulation_executor import household_calculation + + _, smoke = v2_modules + calculated: list[str] = [] + + def calculate(request, *, worker, required_country): + assert required_country == request.country + calculated.append(request.country) + situation = request.household.to_situation() + net_income = situation["households"]["your household"]["household_net_income"] + for period in net_income: + net_income[period] = 20_000.0 + return HouseholdExecutionResult.model_validate( + { + "calculation_id": request.calculation_id, + "output": { + "country": request.country, + "bundle": request.bundle, + "results": [{"policy_key": "baseline", "household": situation}], + }, + "worker": worker, + } + ) + + monkeypatch.setattr(household_calculation, "calculate_household", calculate) + + report = smoke.smoke_v2_household_image() + + assert calculated == ["us", "uk"] + assert report["household_net_income"] == {"us": 20_000.0, "uk": 20_000.0} + assert report["modules"] == [ + "src.modal.v2_app", + "policyengine_simulation_executor.household_calculation", + "policyengine_simulation_executor.household_validation", + ] + + +def test_the_household_image_smoke_fails_when_a_calculation_fails( + monkeypatch, v2_modules +) -> None: + from policyengine_simulation_executor import household_calculation + + _, smoke = v2_modules + + def fail(request, *, worker, required_country): + msg = "model exploded" + raise RuntimeError(msg) + + monkeypatch.setattr(household_calculation, "calculate_household", fail) + + with pytest.raises(RuntimeError, match="model exploded"): + smoke.smoke_v2_household_image() diff --git a/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py b/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py index c5b555c9d..09e156f0a 100644 --- a/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py +++ b/projects/policyengine-simulation-executor/tests/test_validate_v2_worker_release.py @@ -3,9 +3,54 @@ from __future__ import annotations import pytest +from policyengine_simulation_contract.household_execution import ( + HouseholdExecutionRequest, +) from src.modal.utils import validate_v2_worker_release as validation +def _household_outcome( + request: HouseholdExecutionRequest, *, application_name: str, function_name: str +) -> dict: + """Return the success a household function reports for ``request``.""" + situation = request.household.to_situation() + for period in situation["households"]["your household"]["household_net_income"]: + situation["households"]["your household"]["household_net_income"][period] = ( + 25_000.0 + ) + return { + "status": "succeeded", + "schema_version": 1, + "calculation_id": str(request.calculation_id), + "output": { + "country": request.country, + "bundle": request.bundle.model_dump(mode="json"), + "results": [{"policy_key": "baseline", "household": situation}], + }, + "worker": { + "application_name": application_name, + "function_name": function_name, + }, + } + + +class HouseholdFunctions: + """Stands in for the deployed household functions; records each call.""" + + def __init__(self, change_outcome=None) -> None: + self.calls: list[tuple[str, HouseholdExecutionRequest]] = [] + self.change_outcome = change_outcome or (lambda outcome: outcome) + + def __call__(self, *, application_name, function_name, environment, payload): + assert environment == "staging" + request = HouseholdExecutionRequest.model_validate(payload) + self.calls.append((function_name, request)) + outcome = _household_outcome( + request, application_name=application_name, function_name=function_name + ) + return self.change_outcome(outcome), f"household-{request.country}" + + def test_all_country_results_must_match_the_deployment_bundle(monkeypatch) -> None: invocation_ids: list[str] = [] @@ -45,6 +90,8 @@ def fake_spawn_validation(*, application_name, function_name, environment): ) monkeypatch.setattr(validation, "_spawn_validation", fake_spawn_validation) + household_functions = HouseholdFunctions() + monkeypatch.setattr(validation, "_call_household_function", household_functions) result = validation.validate_release( environment="staging", @@ -59,6 +106,16 @@ def fake_spawn_validation(*, application_name, function_name, environment): assert invocation_ids == ["call-us", "call-uk"] assert result.cache is not None assert result.cache.manifest_sha256 == "c" * 64 + # Each household function calculated a current-law household with the + # deployment's bundle, and the record names each invocation. + assert [name for name, _ in household_functions.calls] == [ + "calculate_household_us", + "calculate_household_uk", + ] + assert result.household_validation_invocation_ids == { + "us": "household-us", + "uk": "household-uk", + } def test_country_result_mismatch_stops_publication(monkeypatch) -> None: @@ -80,3 +137,32 @@ def test_country_result_mismatch_stops_publication(monkeypatch) -> None: environment="staging", expected_cache_manifest_sha256="c" * 64, ) + + +def _fail(outcome: dict) -> dict: + return { + "status": "failed", + "schema_version": 1, + "calculation_id": outcome["calculation_id"], + "error": {"code": "calculation_failed", "message": "model exploded"}, + "worker": outcome["worker"], + } + + +def test_a_household_function_that_does_not_calculate_stops_publication( + monkeypatch, +) -> None: + household_functions = HouseholdFunctions(_fail) + monkeypatch.setattr(validation, "_call_household_function", household_functions) + + with pytest.raises( + RuntimeError, + match="calculate_household_us did not calculate the validation household", + ): + validation.validate_household_functions( + application_name="policyengine-simulation-v2-py-test", + environment="staging", + year=2026, + ) + + assert [name for name, _ in household_functions.calls] == ["calculate_household_us"] 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/registry.toml b/rules/registry.toml index f298eb988..c04d73e0e 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,165 @@ 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" + +# --- 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 +438,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 +450,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 +506,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_promotion.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_promotion.py::test_rule_proof_staging_promotes_tested_revision" + [[rule]] id = "sim.deploy.smoke-tests-strict" category = "deployment" @@ -533,9 +687,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 +698,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 +1227,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 +1245,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",