From 64d2d1bf068ca2d234ab8ea5a489560cfd15785c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:19:43 +0400 Subject: [PATCH 01/10] Pin economy and calculation behaviour before the complexity refactors Snapshot the complete economy comparison output (UK and US, every breakdown region and income-change bucket, cliffs) with key order, value types and float reprs, and add focused characterization tests for the economy service's request plumbing, the shared-cache keys and records, the household calculation options and cache reads, SPM selection and worker-result checks, and the release command. Co-Authored-By: Claude Opus 5.5 --- tests/data/economy_comparison_snapshots.json | 8085 +++++++++++++++++ .../test_fake_compare_and_delete.py | 34 + .../test_household_calculation_cache_reads.py | 150 + .../test_runtime_cache_contracts.py | 383 + .../test_economy_comparison_snapshots.py | 534 ++ .../test_economy_service_request_plumbing.py | 420 + .../test_household_calculation_options.py | 202 + .../services/test_worker_spm_result_checks.py | 222 + tests/unit/test_spm_selection_resolution.py | 259 + tests/unit/test_worker_spm_release_command.py | 58 + 10 files changed, 10347 insertions(+) create mode 100644 tests/data/economy_comparison_snapshots.json create mode 100644 tests/unit/runtime_cache/test_fake_compare_and_delete.py create mode 100644 tests/unit/runtime_cache/test_household_calculation_cache_reads.py create mode 100644 tests/unit/runtime_cache/test_runtime_cache_contracts.py create mode 100644 tests/unit/services/test_economy_comparison_snapshots.py create mode 100644 tests/unit/services/test_economy_service_request_plumbing.py create mode 100644 tests/unit/services/test_household_calculation_options.py create mode 100644 tests/unit/services/test_worker_spm_result_checks.py create mode 100644 tests/unit/test_spm_selection_resolution.py create mode 100644 tests/unit/test_worker_spm_release_command.py diff --git a/tests/data/economy_comparison_snapshots.json b/tests/data/economy_comparison_snapshots.json new file mode 100644 index 000000000..821691033 --- /dev/null +++ b/tests/data/economy_comparison_snapshots.json @@ -0,0 +1,8085 @@ +{ + "cases": { + "area_breakdowns_numpy_incomes": "6b76bd61fccf0bbb", + "constituency_breakdown_country_None": "3d7edf7b8d836943", + "constituency_breakdown_country_UK": "3d7edf7b8d836943", + "constituency_breakdown_country_us": "3d7edf7b8d836943", + "constituency_breakdown_region_None": "58627eb566b5096c", + "constituency_breakdown_region_constituency/Constituency 4": "bca9e3f160f4017e", + "constituency_breakdown_region_constituency/Missing": "d86811df62ca768c", + "constituency_breakdown_region_constituency/S14000002": "b92910f1c70bc7be", + "constituency_breakdown_region_country/": "58627eb566b5096c", + "constituency_breakdown_region_country/ENGLAND": "a22d74f249e80a14", + "constituency_breakdown_region_country/Northern_Ireland": "7499530840afae5a", + "constituency_breakdown_region_country/england": "a22d74f249e80a14", + "constituency_breakdown_region_country/northern_ireland": "7499530840afae5a", + "constituency_breakdown_region_country/scotland": "be770bdebb4bc44e", + "constituency_breakdown_region_country/unknown": "58627eb566b5096c", + "constituency_breakdown_region_country/wales": "ea0cb06718c2a3ee", + "constituency_breakdown_region_local_authority/Local authority 4": "3d7edf7b8d836943", + "constituency_breakdown_region_local_authority/Missing": "3d7edf7b8d836943", + "constituency_breakdown_region_local_authority/N06000002": "3d7edf7b8d836943", + "constituency_breakdown_region_state/ca": "58627eb566b5096c", + "constituency_breakdown_region_uk": "58627eb566b5096c", + "local_authority_breakdown_country_None": "3d7edf7b8d836943", + "local_authority_breakdown_country_UK": "3d7edf7b8d836943", + "local_authority_breakdown_country_us": "3d7edf7b8d836943", + "local_authority_breakdown_region_None": "a0f54bfeeaeb5f01", + "local_authority_breakdown_region_constituency/Constituency 4": "3d7edf7b8d836943", + "local_authority_breakdown_region_constituency/Missing": "3d7edf7b8d836943", + "local_authority_breakdown_region_constituency/S14000002": "3d7edf7b8d836943", + "local_authority_breakdown_region_country/": "a0f54bfeeaeb5f01", + "local_authority_breakdown_region_country/ENGLAND": "df9703bc15aed219", + "local_authority_breakdown_region_country/Northern_Ireland": "8f9eacaf36435ed2", + "local_authority_breakdown_region_country/england": "df9703bc15aed219", + "local_authority_breakdown_region_country/northern_ireland": "8f9eacaf36435ed2", + "local_authority_breakdown_region_country/scotland": "5845de9e78b609cd", + "local_authority_breakdown_region_country/unknown": "a0f54bfeeaeb5f01", + "local_authority_breakdown_region_country/wales": "405183757f196d9d", + "local_authority_breakdown_region_local_authority/Local authority 4": "0a623f658d39403c", + "local_authority_breakdown_region_local_authority/Missing": "2b41b426c8388a6e", + "local_authority_breakdown_region_local_authority/N06000002": "b1c4dc99ea923286", + "local_authority_breakdown_region_state/ca": "a0f54bfeeaeb5f01", + "local_authority_breakdown_region_uk": "a0f54bfeeaeb5f01", + "missing_type": "3d7edf7b8d836943", + "uk_cliff": "d51953975fb01911", + "uk_general_all_regions": "93fdec1dfdb6bc3c", + "uk_general_constituency_by_name": "a07d11f0be32b997", + "uk_general_country_scotland": "7440b7b4b4a295d0", + "uk_general_numpy_inputs": "9e199e7f1ac1bad4", + "unknown_type": "3d7edf7b8d836943", + "us_cliff": "d51953975fb01911", + "us_general_national_without_wealth_deciles": "46762aba1acca0b8", + "us_general_state_with_wealth_deciles": "31fd7a13a472c7b8" + }, + "outputs": { + "0a623f658d39403c": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 4" + ], + "/by_local_authority/Local authority 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "2b41b426c8388a6e": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": {}, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "31fd7a13a472c7b8": { + "dataset_calls": [], + "key_order": { + "": [ + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact" + ], + "/budget": [ + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income" + ], + "/decile": [ + "relative", + "average" + ], + "/decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/detailed_budget": [], + "/inequality": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "/inequality/gini": [ + "baseline", + "reform" + ], + "/inequality/top_10_pct_share": [ + "baseline", + "reform" + ], + "/inequality/top_1_pct_share": [ + "baseline", + "reform" + ], + "/intra_decile": [ + "deciles", + "all" + ], + "/intra_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile": [ + "deciles", + "all" + ], + "/intra_wealth_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/labor_supply_response": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "/labor_supply_response/decile": [ + "average", + "relative" + ], + "/labor_supply_response/decile/average": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/average/income": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/average/substitution": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/relative": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/relative/income": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/decile/relative/substitution": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/hours": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "/labor_supply_response/relative_lsr": [ + "income", + "substitution" + ], + "/poverty": [ + "poverty", + "deep_poverty" + ], + "/poverty/deep_poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/deep_poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/all": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/child": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/senior": [ + "baseline", + "reform" + ], + "/poverty/poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/poverty/all": [ + "baseline", + "reform" + ], + "/poverty/poverty/child": [ + "baseline", + "reform" + ], + "/poverty/poverty/senior": [ + "baseline", + "reform" + ], + "/poverty_by_gender": [ + "poverty", + "deep_poverty" + ], + "/poverty_by_gender/deep_poverty": [ + "male", + "female" + ], + "/poverty_by_gender/deep_poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/deep_poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty": [ + "male", + "female" + ], + "/poverty_by_gender/poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_race": [ + "poverty" + ], + "/poverty_by_race/poverty": [ + "white", + "black", + "hispanic", + "other" + ], + "/poverty_by_race/poverty/black": [ + "baseline", + "reform" + ], + "/poverty_by_race/poverty/hispanic": [ + "baseline", + "reform" + ], + "/poverty_by_race/poverty/other": [ + "baseline", + "reform" + ], + "/poverty_by_race/poverty/white": [ + "baseline", + "reform" + ], + "/wealth_decile": [ + "relative", + "average" + ], + "/wealth_decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/wealth_decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ] + }, + "logs": [], + "result": { + "budget": { + "baseline_net_income": "float:1600000000000.0", + "benefit_spending_impact": "float:1750000000.0", + "budgetary_impact": "float:1500000000.0", + "households": "float:28.0", + "state_tax_revenue_impact": "float:-110000000.0", + "tax_revenue_impact": "float:3250000000.0" + }, + "constituency_impact": null, + "decile": { + "average": { + "int:1": "float:26.09166666666715", + "int:10": "float:9.999999999998181", + "int:2": "float:380.37573281250025", + "int:3": "float:433.33333333333394", + "int:4": "float:53.96875", + "int:5": "float:-162.08333333333334", + "int:6": "float:0.0", + "int:7": "float:-2.7999999999992724", + "int:8": "float:-12.5", + "int:9": "float:-436.25" + }, + "relative": { + "int:1": "float:0.0034444444444445086", + "int:10": "float:0.000999999999999818", + "int:2": "float:0.04738961825968467", + "int:3": "float:0.07046070460704616", + "int:4": "float:0.007010351126446113", + "int:5": "float:-0.014171220400728598", + "int:6": "float:nan", + "int:7": "float:-0.00039999999999989604", + "int:8": "float:0.05", + "int:9": "float:-0.05" + } + }, + "detailed_budget": {}, + "inequality": { + "gini": { + "baseline": "float:0.4217", + "reform": "float:0.4186" + }, + "top_10_pct_share": { + "baseline": "float:0.3125", + "reform": "float:0.3083" + }, + "top_1_pct_share": { + "baseline": "float:0.1033", + "reform": "float:0.1022" + } + }, + "intra_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.07692307692307693", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.5571428571428572" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:1.0", + "float:1.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:1.0" + ] + } + } + }, + "intra_wealth_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.15384615384615385", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.4802197802197802" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.7692307692307693", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.0", + "float:0.0", + "float:1.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.23076923076923078", + "float:1.0" + ] + } + } + }, + "labor_supply_response": { + "decile": { + "average": { + "income": { + "float:-1.0": "float:3.625", + "float:1.0": "float:1.2083333333333333", + "float:10.0": "float:3.625", + "float:2.0": "float:-2.265625", + "float:3.0": "float:-1.2083333333333333", + "float:4.0": "float:2.265625", + "float:5.0": "float:-2.4166666666666665", + "float:6.0": "float:-3.625", + "float:7.0": "float:3.625", + "float:8.0": "float:0.0", + "float:9.0": "float:-3.625" + }, + "substitution": { + "float:-1.0": "float:-12.5", + "float:1.0": "float:-12.5", + "float:10.0": "float:12.5", + "float:2.0": "float:-6.25", + "float:3.0": "float:0.0", + "float:4.0": "float:6.25", + "float:5.0": "float:12.5", + "float:6.0": "float:-12.5", + "float:7.0": "float:-6.25", + "float:8.0": "float:0.0", + "float:9.0": "float:6.25" + } + }, + "relative": { + "income": { + "int:1": "float:3.598590841462219e-05", + "int:10": "float:0.00010826106596085429", + "int:2": "float:-7.336033330887989e-05", + "int:3": "float:-3.829348390155141e-05", + "int:4": "float:7.522873426357554e-05", + "int:5": "float:-6.1032201852432556e-05", + "int:6": "float:-0.00013174093372522475", + "int:7": "float:0.00011132394885240364", + "int:8": "float:0.0", + "int:9": "float:-0.0001018905976059223" + }, + "substitution": { + "int:1": "float:-0.00037226801808229855", + "int:10": "float:0.00037331402055466997", + "int:2": "float:-0.00020237333326587556", + "int:3": "float:0.0", + "int:4": "float:0.00020752754279607045", + "int:5": "float:0.000315683802684996", + "int:6": "float:-0.00045427908181111987", + "int:7": "float:-0.00019193784284897178", + "int:8": "float:0.0", + "int:9": "float:0.0001756734441481419" + } + } + }, + "hours": { + "baseline": "float:1180000000.0", + "change": "float:2300000.0", + "income_effect": "float:1000000.0", + "reform": "float:1182300000.0", + "substitution_effect": "float:-3000000.0" + }, + "income_lsr": "float:-75000000.0", + "relative_lsr": { + "income": "float64:5.8285236289352835e-06", + "substitution": "float64:-4.01967146823123e-05" + }, + "revenue_change": "float:400000000.0", + "substitution_lsr": "float:125000000.0", + "total_change": "float:50000000.0" + }, + "local_authority_impact": null, + "poverty": { + "deep_poverty": { + "adult": { + "baseline": "float:0.1346153846153846", + "reform": "float:0.1346153846153846" + }, + "all": { + "baseline": "float:0.1523809523809524", + "reform": "float:0.06666666666666667" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.0" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + }, + "poverty": { + "adult": { + "baseline": "float:0.40384615384615385", + "reform": "float:0.40384615384615385" + }, + "all": { + "baseline": "float:0.2857142857142857", + "reform": "float:0.37142857142857144" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.5625" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + } + }, + "poverty_by_gender": { + "deep_poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "male": { + "baseline": "float:0.3018867924528302", + "reform": "float:0.1320754716981132" + } + }, + "poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.09615384615384616" + }, + "male": { + "baseline": "float:0.5660377358490566", + "reform": "float:0.6415094339622641" + } + } + }, + "poverty_by_race": { + "poverty": { + "black": { + "baseline": "float:0.0", + "reform": "float:0.16666666666666666" + }, + "hispanic": { + "baseline": "float:0.0", + "reform": "float:0.17391304347826086" + }, + "other": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "white": { + "baseline": "float:1.0", + "reform": "float:1.0" + } + } + }, + "wealth_decile": { + "average": { + "int:1": "float:-12.5", + "int:10": "float:-2.7999999999992724", + "int:2": "float:-436.25", + "int:3": "float:9.999999999998181", + "int:4": "float:26.09166666666715", + "int:5": "float:380.37573281250025", + "int:6": "float:433.33333333333394", + "int:7": "float:53.96875", + "int:8": "float:-162.08333333333334", + "int:9": "float:-200.0" + }, + "relative": { + "int:1": "float:0.05", + "int:10": "float:-0.00039999999999989604", + "int:2": "float:-0.05", + "int:3": "float:0.000999999999999818", + "int:4": "float:0.0034444444444445086", + "int:5": "float:0.04738961825968467", + "int:6": "float:0.07046070460704616", + "int:7": "float:0.007010351126446113", + "int:8": "float:-0.014171220400728598", + "int:9": "float:-0.02" + } + } + }, + "result_type": "dict" + }, + "3d7edf7b8d836943": { + "dataset_calls": [], + "key_order": {}, + "logs": [], + "result": null, + "result_type": "NoneType" + }, + "405183757f196d9d": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 2" + ], + "/by_local_authority/Local authority 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "46762aba1acca0b8": { + "dataset_calls": [], + "key_order": { + "": [ + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact" + ], + "/budget": [ + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income" + ], + "/decile": [ + "relative", + "average" + ], + "/decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/detailed_budget": [], + "/inequality": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "/inequality/gini": [ + "baseline", + "reform" + ], + "/inequality/top_10_pct_share": [ + "baseline", + "reform" + ], + "/inequality/top_1_pct_share": [ + "baseline", + "reform" + ], + "/intra_decile": [ + "deciles", + "all" + ], + "/intra_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile": [], + "/labor_supply_response": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "/labor_supply_response/decile": [ + "average", + "relative" + ], + "/labor_supply_response/decile/average": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/average/income": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/average/substitution": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/relative": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/relative/income": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/decile/relative/substitution": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/hours": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "/labor_supply_response/relative_lsr": [ + "income", + "substitution" + ], + "/poverty": [ + "poverty", + "deep_poverty" + ], + "/poverty/deep_poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/deep_poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/all": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/child": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/senior": [ + "baseline", + "reform" + ], + "/poverty/poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/poverty/all": [ + "baseline", + "reform" + ], + "/poverty/poverty/child": [ + "baseline", + "reform" + ], + "/poverty/poverty/senior": [ + "baseline", + "reform" + ], + "/poverty_by_gender": [ + "poverty", + "deep_poverty" + ], + "/poverty_by_gender/deep_poverty": [ + "male", + "female" + ], + "/poverty_by_gender/deep_poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/deep_poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty": [ + "male", + "female" + ], + "/poverty_by_gender/poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_race": [ + "poverty" + ], + "/poverty_by_race/poverty": [ + "white", + "black", + "hispanic", + "other" + ], + "/poverty_by_race/poverty/black": [ + "baseline", + "reform" + ], + "/poverty_by_race/poverty/hispanic": [ + "baseline", + "reform" + ], + "/poverty_by_race/poverty/other": [ + "baseline", + "reform" + ], + "/poverty_by_race/poverty/white": [ + "baseline", + "reform" + ], + "/wealth_decile": [] + }, + "logs": [ + [ + "policyengine_api.services.economy_comparison", + "ERROR", + "Wealth decile impact computation failed; returning empty breakdowns.", + "KeyError" + ] + ], + "result": { + "budget": { + "baseline_net_income": "float:1600000000000.0", + "benefit_spending_impact": "float:1750000000.0", + "budgetary_impact": "float:1500000000.0", + "households": "float:28.0", + "state_tax_revenue_impact": "float:-110000000.0", + "tax_revenue_impact": "float:3250000000.0" + }, + "constituency_impact": null, + "decile": { + "average": { + "int:1": "float:26.09166666666715", + "int:10": "float:9.999999999998181", + "int:2": "float:380.37573281250025", + "int:3": "float:433.33333333333394", + "int:4": "float:53.96875", + "int:5": "float:-162.08333333333334", + "int:6": "float:0.0", + "int:7": "float:-2.7999999999992724", + "int:8": "float:-12.5", + "int:9": "float:-436.25" + }, + "relative": { + "int:1": "float:0.0034444444444445086", + "int:10": "float:0.000999999999999818", + "int:2": "float:0.04738961825968467", + "int:3": "float:0.07046070460704616", + "int:4": "float:0.007010351126446113", + "int:5": "float:-0.014171220400728598", + "int:6": "float:nan", + "int:7": "float:-0.00039999999999989604", + "int:8": "float:0.05", + "int:9": "float:-0.05" + } + }, + "detailed_budget": {}, + "inequality": { + "gini": { + "baseline": "float:0.4217", + "reform": "float:0.4186" + }, + "top_10_pct_share": { + "baseline": "float:0.3125", + "reform": "float:0.3083" + }, + "top_1_pct_share": { + "baseline": "float:0.1033", + "reform": "float:0.1022" + } + }, + "intra_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.07692307692307693", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.5571428571428572" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:1.0", + "float:1.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:1.0" + ] + } + } + }, + "intra_wealth_decile": {}, + "labor_supply_response": { + "decile": { + "average": { + "income": { + "float:-1.0": "float:3.625", + "float:1.0": "float:1.2083333333333333", + "float:10.0": "float:3.625", + "float:2.0": "float:-2.265625", + "float:3.0": "float:-1.2083333333333333", + "float:4.0": "float:2.265625", + "float:5.0": "float:-2.4166666666666665", + "float:6.0": "float:-3.625", + "float:7.0": "float:3.625", + "float:8.0": "float:0.0", + "float:9.0": "float:-3.625" + }, + "substitution": { + "float:-1.0": "float:-12.5", + "float:1.0": "float:-12.5", + "float:10.0": "float:12.5", + "float:2.0": "float:-6.25", + "float:3.0": "float:0.0", + "float:4.0": "float:6.25", + "float:5.0": "float:12.5", + "float:6.0": "float:-12.5", + "float:7.0": "float:-6.25", + "float:8.0": "float:0.0", + "float:9.0": "float:6.25" + } + }, + "relative": { + "income": { + "int:1": "float:3.598590841462219e-05", + "int:10": "float:0.00010826106596085429", + "int:2": "float:-7.336033330887989e-05", + "int:3": "float:-3.829348390155141e-05", + "int:4": "float:7.522873426357554e-05", + "int:5": "float:-6.1032201852432556e-05", + "int:6": "float:-0.00013174093372522475", + "int:7": "float:0.00011132394885240364", + "int:8": "float:0.0", + "int:9": "float:-0.0001018905976059223" + }, + "substitution": { + "int:1": "float:-0.00037226801808229855", + "int:10": "float:0.00037331402055466997", + "int:2": "float:-0.00020237333326587556", + "int:3": "float:0.0", + "int:4": "float:0.00020752754279607045", + "int:5": "float:0.000315683802684996", + "int:6": "float:-0.00045427908181111987", + "int:7": "float:-0.00019193784284897178", + "int:8": "float:0.0", + "int:9": "float:0.0001756734441481419" + } + } + }, + "hours": { + "baseline": "float:1180000000.0", + "change": "float:2300000.0", + "income_effect": "float:1000000.0", + "reform": "float:1182300000.0", + "substitution_effect": "float:-3000000.0" + }, + "income_lsr": "float:-75000000.0", + "relative_lsr": { + "income": "float64:5.8285236289352835e-06", + "substitution": "float64:-4.01967146823123e-05" + }, + "revenue_change": "float:400000000.0", + "substitution_lsr": "float:125000000.0", + "total_change": "float:50000000.0" + }, + "local_authority_impact": null, + "poverty": { + "deep_poverty": { + "adult": { + "baseline": "float:0.1346153846153846", + "reform": "float:0.1346153846153846" + }, + "all": { + "baseline": "float:0.1523809523809524", + "reform": "float:0.06666666666666667" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.0" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + }, + "poverty": { + "adult": { + "baseline": "float:0.40384615384615385", + "reform": "float:0.40384615384615385" + }, + "all": { + "baseline": "float:0.2857142857142857", + "reform": "float:0.37142857142857144" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.5625" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + } + }, + "poverty_by_gender": { + "deep_poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "male": { + "baseline": "float:0.3018867924528302", + "reform": "float:0.1320754716981132" + } + }, + "poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.09615384615384616" + }, + "male": { + "baseline": "float:0.5660377358490566", + "reform": "float:0.6415094339622641" + } + } + }, + "poverty_by_race": { + "poverty": { + "black": { + "baseline": "float:0.0", + "reform": "float:0.16666666666666666" + }, + "hispanic": { + "baseline": "float:0.0", + "reform": "float:0.17391304347826086" + }, + "other": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "white": { + "baseline": "float:1.0", + "reform": "float:1.0" + } + } + }, + "wealth_decile": {} + }, + "result_type": "dict" + }, + "5845de9e78b609cd": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 1", + "Local authority 6" + ], + "/by_local_authority/Local authority 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Local authority 6": { + "average_household_income_change": "float:189.81651617187526", + "relative_household_income_change": "float:0.029066292702476515", + "x": "int:4", + "y": "int:7" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "58627eb566b5096c": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 0", + "Constituency 1", + "Constituency 2", + "Constituency 3", + "Constituency 4", + "Constituency 5", + "Constituency 6", + "Constituency 7" + ], + "/by_constituency/Constituency 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 5": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Constituency 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Constituency 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + }, + "Constituency 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Constituency 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + }, + "Constituency 5": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:nan", + "x": "int:1", + "y": "int:5" + }, + "Constituency 6": { + "average_household_income_change": "float:19.123372416666502", + "relative_household_income_change": "float:0.002298021920688953", + "x": "int:4", + "y": "int:7" + }, + "Constituency 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:2", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:2", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "6b76bd61fccf0bbb": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ], + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "constituency", + "local_authority" + ], + "/constituency": [ + "by_constituency", + "outcomes_by_region" + ], + "/constituency/by_constituency": [ + "Constituency 0", + "Constituency 1", + "Constituency 2", + "Constituency 3", + "Constituency 4", + "Constituency 5", + "Constituency 6", + "Constituency 7" + ], + "/constituency/by_constituency/Constituency 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 5": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/by_constituency/Constituency 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/constituency/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority": [ + "by_local_authority", + "outcomes_by_region" + ], + "/local_authority/by_local_authority": [ + "Local authority 0", + "Local authority 1", + "Local authority 2", + "Local authority 3", + "Local authority 4", + "Local authority 5", + "Local authority 6", + "Local authority 7" + ], + "/local_authority/by_local_authority/Local authority 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 5": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/by_local_authority/Local authority 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/local_authority/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "constituency": { + "fields": { + "by_constituency": { + "Constituency 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Constituency 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Constituency 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + }, + "Constituency 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Constituency 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + }, + "Constituency 5": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:nan", + "x": "int:1", + "y": "int:5" + }, + "Constituency 6": { + "average_household_income_change": "float:19.123372416666502", + "relative_household_income_change": "float:0.002298021920688953", + "x": "int:4", + "y": "int:7" + }, + "Constituency 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:2", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:2", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "local_authority": { + "fields": { + "by_local_authority": { + "Local authority 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Local authority 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Local authority 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + }, + "Local authority 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Local authority 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + }, + "Local authority 5": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:nan", + "x": "int:1", + "y": "int:5" + }, + "Local authority 6": { + "average_household_income_change": "float:189.81651617187526", + "relative_household_income_change": "float:0.029066292702476515", + "x": "int:4", + "y": "int:7" + }, + "Local authority 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:2", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:2", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + } + }, + "result_type": "dict" + }, + "7440b7b4b4a295d0": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ], + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact" + ], + "/budget": [ + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income" + ], + "/constituency_impact": [ + "by_constituency", + "outcomes_by_region" + ], + "/constituency_impact/by_constituency": [ + "Constituency 1", + "Constituency 6" + ], + "/constituency_impact/by_constituency/Constituency 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/constituency_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/decile": [ + "relative", + "average" + ], + "/decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/detailed_budget": [ + "income_tax", + "national_insurance", + "universal_credit" + ], + "/detailed_budget/income_tax": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/national_insurance": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/universal_credit": [ + "baseline", + "reform", + "difference" + ], + "/inequality": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "/inequality/gini": [ + "baseline", + "reform" + ], + "/inequality/top_10_pct_share": [ + "baseline", + "reform" + ], + "/inequality/top_1_pct_share": [ + "baseline", + "reform" + ], + "/intra_decile": [ + "deciles", + "all" + ], + "/intra_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile": [ + "deciles", + "all" + ], + "/intra_wealth_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/labor_supply_response": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "/labor_supply_response/decile": [ + "average", + "relative" + ], + "/labor_supply_response/decile/average": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/average/income": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/average/substitution": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/relative": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/relative/income": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/decile/relative/substitution": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/hours": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "/labor_supply_response/relative_lsr": [ + "income", + "substitution" + ], + "/local_authority_impact": [ + "by_local_authority", + "outcomes_by_region" + ], + "/local_authority_impact/by_local_authority": [ + "Local authority 1", + "Local authority 6" + ], + "/local_authority_impact/by_local_authority/Local authority 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/local_authority_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/poverty": [ + "poverty", + "deep_poverty" + ], + "/poverty/deep_poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/deep_poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/all": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/child": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/senior": [ + "baseline", + "reform" + ], + "/poverty/poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/poverty/all": [ + "baseline", + "reform" + ], + "/poverty/poverty/child": [ + "baseline", + "reform" + ], + "/poverty/poverty/senior": [ + "baseline", + "reform" + ], + "/poverty_by_gender": [ + "poverty", + "deep_poverty" + ], + "/poverty_by_gender/deep_poverty": [ + "male", + "female" + ], + "/poverty_by_gender/deep_poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/deep_poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty": [ + "male", + "female" + ], + "/poverty_by_gender/poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_race": [], + "/wealth_decile": [ + "relative", + "average" + ], + "/wealth_decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/wealth_decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ] + }, + "logs": [], + "result": { + "budget": { + "baseline_net_income": "float:1600000000000.0", + "benefit_spending_impact": "float:1750000000.0", + "budgetary_impact": "float:1500000000.0", + "households": "float:28.0", + "state_tax_revenue_impact": "float:-110000000.0", + "tax_revenue_impact": "float:3250000000.0" + }, + "constituency_impact": { + "by_constituency": { + "Constituency 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Constituency 6": { + "average_household_income_change": "float:19.123372416666502", + "relative_household_income_change": "float:0.002298021920688953", + "x": "int:4", + "y": "int:7" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "decile": { + "average": { + "int:1": "float:26.09166666666715", + "int:10": "float:9.999999999998181", + "int:2": "float:380.37573281250025", + "int:3": "float:433.33333333333394", + "int:4": "float:53.96875", + "int:5": "float:-162.08333333333334", + "int:6": "float:0.0", + "int:7": "float:-2.7999999999992724", + "int:8": "float:-12.5", + "int:9": "float:-436.25" + }, + "relative": { + "int:1": "float:0.0034444444444445086", + "int:10": "float:0.000999999999999818", + "int:2": "float:0.04738961825968467", + "int:3": "float:0.07046070460704616", + "int:4": "float:0.007010351126446113", + "int:5": "float:-0.014171220400728598", + "int:6": "float:nan", + "int:7": "float:-0.00039999999999989604", + "int:8": "float:0.05", + "int:9": "float:-0.05" + } + }, + "detailed_budget": { + "income_tax": { + "baseline": "float:240000000000.0", + "difference": "float:1500000000.0", + "reform": "float:241500000000.0" + }, + "national_insurance": { + "baseline": "float:160000000000.0", + "difference": "float:-200000000.0", + "reform": "float:159800000000.0" + }, + "universal_credit": { + "baseline": "float:61000000000.0", + "difference": "float:750000000.0", + "reform": "float:61750000000.0" + } + }, + "inequality": { + "gini": { + "baseline": "float:0.4217", + "reform": "float:0.4186" + }, + "top_10_pct_share": { + "baseline": "float:0.3125", + "reform": "float:0.3083" + }, + "top_1_pct_share": { + "baseline": "float:0.1033", + "reform": "float:0.1022" + } + }, + "intra_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.07692307692307693", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.5571428571428572" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:1.0", + "float:1.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:1.0" + ] + } + } + }, + "intra_wealth_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.15384615384615385", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.4802197802197802" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.7692307692307693", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.0", + "float:0.0", + "float:1.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.23076923076923078", + "float:1.0" + ] + } + } + }, + "labor_supply_response": { + "decile": { + "average": { + "income": { + "float:-1.0": "float:3.625", + "float:1.0": "float:1.2083333333333333", + "float:10.0": "float:3.625", + "float:2.0": "float:-2.265625", + "float:3.0": "float:-1.2083333333333333", + "float:4.0": "float:2.265625", + "float:5.0": "float:-2.4166666666666665", + "float:6.0": "float:-3.625", + "float:7.0": "float:3.625", + "float:8.0": "float:0.0", + "float:9.0": "float:-3.625" + }, + "substitution": { + "float:-1.0": "float:-12.5", + "float:1.0": "float:-12.5", + "float:10.0": "float:12.5", + "float:2.0": "float:-6.25", + "float:3.0": "float:0.0", + "float:4.0": "float:6.25", + "float:5.0": "float:12.5", + "float:6.0": "float:-12.5", + "float:7.0": "float:-6.25", + "float:8.0": "float:0.0", + "float:9.0": "float:6.25" + } + }, + "relative": { + "income": { + "int:1": "float:3.598590841462219e-05", + "int:10": "float:0.00010826106596085429", + "int:2": "float:-7.336033330887989e-05", + "int:3": "float:-3.829348390155141e-05", + "int:4": "float:7.522873426357554e-05", + "int:5": "float:-6.1032201852432556e-05", + "int:6": "float:-0.00013174093372522475", + "int:7": "float:0.00011132394885240364", + "int:8": "float:0.0", + "int:9": "float:-0.0001018905976059223" + }, + "substitution": { + "int:1": "float:-0.00037226801808229855", + "int:10": "float:0.00037331402055466997", + "int:2": "float:-0.00020237333326587556", + "int:3": "float:0.0", + "int:4": "float:0.00020752754279607045", + "int:5": "float:0.000315683802684996", + "int:6": "float:-0.00045427908181111987", + "int:7": "float:-0.00019193784284897178", + "int:8": "float:0.0", + "int:9": "float:0.0001756734441481419" + } + } + }, + "hours": { + "baseline": "float:1180000000.0", + "change": "float:2300000.0", + "income_effect": "float:1000000.0", + "reform": "float:1182300000.0", + "substitution_effect": "float:-3000000.0" + }, + "income_lsr": "float:-75000000.0", + "relative_lsr": { + "income": "float64:5.8285236289352835e-06", + "substitution": "float64:-4.01967146823123e-05" + }, + "revenue_change": "float:400000000.0", + "substitution_lsr": "float:125000000.0", + "total_change": "float:50000000.0" + }, + "local_authority_impact": { + "by_local_authority": { + "Local authority 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Local authority 6": { + "average_household_income_change": "float:189.81651617187526", + "relative_household_income_change": "float:0.029066292702476515", + "x": "int:4", + "y": "int:7" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "poverty": { + "deep_poverty": { + "adult": { + "baseline": "float:0.1346153846153846", + "reform": "float:0.1346153846153846" + }, + "all": { + "baseline": "float:0.1523809523809524", + "reform": "float:0.06666666666666667" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.0" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + }, + "poverty": { + "adult": { + "baseline": "float:0.40384615384615385", + "reform": "float:0.40384615384615385" + }, + "all": { + "baseline": "float:0.2857142857142857", + "reform": "float:0.37142857142857144" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.5625" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + } + }, + "poverty_by_gender": { + "deep_poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "male": { + "baseline": "float:0.3018867924528302", + "reform": "float:0.1320754716981132" + } + }, + "poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.09615384615384616" + }, + "male": { + "baseline": "float:0.5660377358490566", + "reform": "float:0.6415094339622641" + } + } + }, + "poverty_by_race": {}, + "wealth_decile": { + "average": { + "int:1": "float:-12.5", + "int:10": "float:-2.7999999999992724", + "int:2": "float:-436.25", + "int:3": "float:9.999999999998181", + "int:4": "float:26.09166666666715", + "int:5": "float:380.37573281250025", + "int:6": "float:433.33333333333394", + "int:7": "float:53.96875", + "int:8": "float:-162.08333333333334", + "int:9": "float:-200.0" + }, + "relative": { + "int:1": "float:0.05", + "int:10": "float:-0.00039999999999989604", + "int:2": "float:-0.05", + "int:3": "float:0.000999999999999818", + "int:4": "float:0.0034444444444445086", + "int:5": "float:0.04738961825968467", + "int:6": "float:0.07046070460704616", + "int:7": "float:0.007010351126446113", + "int:8": "float:-0.014171220400728598", + "int:9": "float:-0.02" + } + } + }, + "result_type": "dict" + }, + "7499530840afae5a": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 3", + "Constituency 7" + ], + "/by_constituency/Constituency 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Constituency 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "8f9eacaf36435ed2": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 3", + "Local authority 7" + ], + "/by_local_authority/Local authority 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Local authority 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "93fdec1dfdb6bc3c": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ], + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact" + ], + "/budget": [ + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income" + ], + "/constituency_impact": [ + "by_constituency", + "outcomes_by_region" + ], + "/constituency_impact/by_constituency": [ + "Constituency 0", + "Constituency 1", + "Constituency 2", + "Constituency 3", + "Constituency 4", + "Constituency 5", + "Constituency 6", + "Constituency 7" + ], + "/constituency_impact/by_constituency/Constituency 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 5": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/constituency_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/decile": [ + "relative", + "average" + ], + "/decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/detailed_budget": [ + "income_tax", + "national_insurance", + "universal_credit" + ], + "/detailed_budget/income_tax": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/national_insurance": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/universal_credit": [ + "baseline", + "reform", + "difference" + ], + "/inequality": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "/inequality/gini": [ + "baseline", + "reform" + ], + "/inequality/top_10_pct_share": [ + "baseline", + "reform" + ], + "/inequality/top_1_pct_share": [ + "baseline", + "reform" + ], + "/intra_decile": [ + "deciles", + "all" + ], + "/intra_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile": [ + "deciles", + "all" + ], + "/intra_wealth_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/labor_supply_response": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "/labor_supply_response/decile": [ + "average", + "relative" + ], + "/labor_supply_response/decile/average": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/average/income": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/average/substitution": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/relative": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/relative/income": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/decile/relative/substitution": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/hours": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "/labor_supply_response/relative_lsr": [ + "income", + "substitution" + ], + "/local_authority_impact": [ + "by_local_authority", + "outcomes_by_region" + ], + "/local_authority_impact/by_local_authority": [ + "Local authority 0", + "Local authority 1", + "Local authority 2", + "Local authority 3", + "Local authority 4", + "Local authority 5", + "Local authority 6", + "Local authority 7" + ], + "/local_authority_impact/by_local_authority/Local authority 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 5": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/local_authority_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/poverty": [ + "poverty", + "deep_poverty" + ], + "/poverty/deep_poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/deep_poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/all": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/child": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/senior": [ + "baseline", + "reform" + ], + "/poverty/poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/poverty/all": [ + "baseline", + "reform" + ], + "/poverty/poverty/child": [ + "baseline", + "reform" + ], + "/poverty/poverty/senior": [ + "baseline", + "reform" + ], + "/poverty_by_gender": [ + "poverty", + "deep_poverty" + ], + "/poverty_by_gender/deep_poverty": [ + "male", + "female" + ], + "/poverty_by_gender/deep_poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/deep_poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty": [ + "male", + "female" + ], + "/poverty_by_gender/poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_race": [], + "/wealth_decile": [ + "relative", + "average" + ], + "/wealth_decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/wealth_decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ] + }, + "logs": [], + "result": { + "budget": { + "baseline_net_income": "float:1600000000000.0", + "benefit_spending_impact": "float:1750000000.0", + "budgetary_impact": "float:1500000000.0", + "households": "float:28.0", + "state_tax_revenue_impact": "float:-110000000.0", + "tax_revenue_impact": "float:3250000000.0" + }, + "constituency_impact": { + "by_constituency": { + "Constituency 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Constituency 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Constituency 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + }, + "Constituency 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Constituency 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + }, + "Constituency 5": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:nan", + "x": "int:1", + "y": "int:5" + }, + "Constituency 6": { + "average_household_income_change": "float:19.123372416666502", + "relative_household_income_change": "float:0.002298021920688953", + "x": "int:4", + "y": "int:7" + }, + "Constituency 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:2", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:2", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "decile": { + "average": { + "int:1": "float:26.09166666666715", + "int:10": "float:9.999999999998181", + "int:2": "float:380.37573281250025", + "int:3": "float:433.33333333333394", + "int:4": "float:53.96875", + "int:5": "float:-162.08333333333334", + "int:6": "float:0.0", + "int:7": "float:-2.7999999999992724", + "int:8": "float:-12.5", + "int:9": "float:-436.25" + }, + "relative": { + "int:1": "float:0.0034444444444445086", + "int:10": "float:0.000999999999999818", + "int:2": "float:0.04738961825968467", + "int:3": "float:0.07046070460704616", + "int:4": "float:0.007010351126446113", + "int:5": "float:-0.014171220400728598", + "int:6": "float:nan", + "int:7": "float:-0.00039999999999989604", + "int:8": "float:0.05", + "int:9": "float:-0.05" + } + }, + "detailed_budget": { + "income_tax": { + "baseline": "float:240000000000.0", + "difference": "float:1500000000.0", + "reform": "float:241500000000.0" + }, + "national_insurance": { + "baseline": "float:160000000000.0", + "difference": "float:-200000000.0", + "reform": "float:159800000000.0" + }, + "universal_credit": { + "baseline": "float:61000000000.0", + "difference": "float:750000000.0", + "reform": "float:61750000000.0" + } + }, + "inequality": { + "gini": { + "baseline": "float:0.4217", + "reform": "float:0.4186" + }, + "top_10_pct_share": { + "baseline": "float:0.3125", + "reform": "float:0.3083" + }, + "top_1_pct_share": { + "baseline": "float:0.1033", + "reform": "float:0.1022" + } + }, + "intra_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.07692307692307693", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.5571428571428572" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:1.0", + "float:1.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:1.0" + ] + } + } + }, + "intra_wealth_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.15384615384615385", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.4802197802197802" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.7692307692307693", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.0", + "float:0.0", + "float:1.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.23076923076923078", + "float:1.0" + ] + } + } + }, + "labor_supply_response": { + "decile": { + "average": { + "income": { + "float:-1.0": "float:3.625", + "float:1.0": "float:1.2083333333333333", + "float:10.0": "float:3.625", + "float:2.0": "float:-2.265625", + "float:3.0": "float:-1.2083333333333333", + "float:4.0": "float:2.265625", + "float:5.0": "float:-2.4166666666666665", + "float:6.0": "float:-3.625", + "float:7.0": "float:3.625", + "float:8.0": "float:0.0", + "float:9.0": "float:-3.625" + }, + "substitution": { + "float:-1.0": "float:-12.5", + "float:1.0": "float:-12.5", + "float:10.0": "float:12.5", + "float:2.0": "float:-6.25", + "float:3.0": "float:0.0", + "float:4.0": "float:6.25", + "float:5.0": "float:12.5", + "float:6.0": "float:-12.5", + "float:7.0": "float:-6.25", + "float:8.0": "float:0.0", + "float:9.0": "float:6.25" + } + }, + "relative": { + "income": { + "int:1": "float:3.598590841462219e-05", + "int:10": "float:0.00010826106596085429", + "int:2": "float:-7.336033330887989e-05", + "int:3": "float:-3.829348390155141e-05", + "int:4": "float:7.522873426357554e-05", + "int:5": "float:-6.1032201852432556e-05", + "int:6": "float:-0.00013174093372522475", + "int:7": "float:0.00011132394885240364", + "int:8": "float:0.0", + "int:9": "float:-0.0001018905976059223" + }, + "substitution": { + "int:1": "float:-0.00037226801808229855", + "int:10": "float:0.00037331402055466997", + "int:2": "float:-0.00020237333326587556", + "int:3": "float:0.0", + "int:4": "float:0.00020752754279607045", + "int:5": "float:0.000315683802684996", + "int:6": "float:-0.00045427908181111987", + "int:7": "float:-0.00019193784284897178", + "int:8": "float:0.0", + "int:9": "float:0.0001756734441481419" + } + } + }, + "hours": { + "baseline": "float:1180000000.0", + "change": "float:2300000.0", + "income_effect": "float:1000000.0", + "reform": "float:1182300000.0", + "substitution_effect": "float:-3000000.0" + }, + "income_lsr": "float:-75000000.0", + "relative_lsr": { + "income": "float64:5.8285236289352835e-06", + "substitution": "float64:-4.01967146823123e-05" + }, + "revenue_change": "float:400000000.0", + "substitution_lsr": "float:125000000.0", + "total_change": "float:50000000.0" + }, + "local_authority_impact": { + "by_local_authority": { + "Local authority 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Local authority 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Local authority 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + }, + "Local authority 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Local authority 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + }, + "Local authority 5": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:nan", + "x": "int:1", + "y": "int:5" + }, + "Local authority 6": { + "average_household_income_change": "float:189.81651617187526", + "relative_household_income_change": "float:0.029066292702476515", + "x": "int:4", + "y": "int:7" + }, + "Local authority 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:2", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:2", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "poverty": { + "deep_poverty": { + "adult": { + "baseline": "float:0.1346153846153846", + "reform": "float:0.1346153846153846" + }, + "all": { + "baseline": "float:0.1523809523809524", + "reform": "float:0.06666666666666667" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.0" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + }, + "poverty": { + "adult": { + "baseline": "float:0.40384615384615385", + "reform": "float:0.40384615384615385" + }, + "all": { + "baseline": "float:0.2857142857142857", + "reform": "float:0.37142857142857144" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.5625" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + } + }, + "poverty_by_gender": { + "deep_poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "male": { + "baseline": "float:0.3018867924528302", + "reform": "float:0.1320754716981132" + } + }, + "poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.09615384615384616" + }, + "male": { + "baseline": "float:0.5660377358490566", + "reform": "float:0.6415094339622641" + } + } + }, + "poverty_by_race": {}, + "wealth_decile": { + "average": { + "int:1": "float:-12.5", + "int:10": "float:-2.7999999999992724", + "int:2": "float:-436.25", + "int:3": "float:9.999999999998181", + "int:4": "float:26.09166666666715", + "int:5": "float:380.37573281250025", + "int:6": "float:433.33333333333394", + "int:7": "float:53.96875", + "int:8": "float:-162.08333333333334", + "int:9": "float:-200.0" + }, + "relative": { + "int:1": "float:0.05", + "int:10": "float:-0.00039999999999989604", + "int:2": "float:-0.05", + "int:3": "float:0.000999999999999818", + "int:4": "float:0.0034444444444445086", + "int:5": "float:0.04738961825968467", + "int:6": "float:0.07046070460704616", + "int:7": "float:0.007010351126446113", + "int:8": "float:-0.014171220400728598", + "int:9": "float:-0.02" + } + } + }, + "result_type": "dict" + }, + "9e199e7f1ac1bad4": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ], + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact" + ], + "/budget": [ + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income" + ], + "/constituency_impact": [ + "by_constituency", + "outcomes_by_region" + ], + "/constituency_impact/by_constituency": [ + "Constituency 3", + "Constituency 7" + ], + "/constituency_impact/by_constituency/Constituency 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/by_constituency/Constituency 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/constituency_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/decile": [ + "relative", + "average" + ], + "/decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/detailed_budget": [ + "income_tax", + "national_insurance", + "universal_credit" + ], + "/detailed_budget/income_tax": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/national_insurance": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/universal_credit": [ + "baseline", + "reform", + "difference" + ], + "/inequality": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "/inequality/gini": [ + "baseline", + "reform" + ], + "/inequality/top_10_pct_share": [ + "baseline", + "reform" + ], + "/inequality/top_1_pct_share": [ + "baseline", + "reform" + ], + "/intra_decile": [ + "deciles", + "all" + ], + "/intra_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile": [ + "deciles", + "all" + ], + "/intra_wealth_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/labor_supply_response": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "/labor_supply_response/decile": [ + "average", + "relative" + ], + "/labor_supply_response/decile/average": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/average/income": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/average/substitution": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/relative": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/relative/income": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/decile/relative/substitution": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/hours": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "/labor_supply_response/relative_lsr": [ + "income", + "substitution" + ], + "/local_authority_impact": [ + "by_local_authority", + "outcomes_by_region" + ], + "/local_authority_impact/by_local_authority": [ + "Local authority 3", + "Local authority 7" + ], + "/local_authority_impact/by_local_authority/Local authority 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/by_local_authority/Local authority 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/local_authority_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/local_authority_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/local_authority_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/poverty": [ + "poverty", + "deep_poverty" + ], + "/poverty/deep_poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/deep_poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/all": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/child": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/senior": [ + "baseline", + "reform" + ], + "/poverty/poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/poverty/all": [ + "baseline", + "reform" + ], + "/poverty/poverty/child": [ + "baseline", + "reform" + ], + "/poverty/poverty/senior": [ + "baseline", + "reform" + ], + "/poverty_by_gender": [ + "poverty", + "deep_poverty" + ], + "/poverty_by_gender/deep_poverty": [ + "male", + "female" + ], + "/poverty_by_gender/deep_poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/deep_poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty": [ + "male", + "female" + ], + "/poverty_by_gender/poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_race": [], + "/wealth_decile": [ + "relative", + "average" + ], + "/wealth_decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/wealth_decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ] + }, + "logs": [], + "result": { + "budget": { + "baseline_net_income": "float:1600000000000.0", + "benefit_spending_impact": "float:1750000000.0", + "budgetary_impact": "float:1500000000.0", + "households": "float64:28.0", + "state_tax_revenue_impact": "float:-110000000.0", + "tax_revenue_impact": "float:3250000000.0" + }, + "constituency_impact": { + "by_constituency": { + "Constituency 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Constituency 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "decile": { + "average": { + "int:1": "float:26.09166666666715", + "int:10": "float:9.999999999998181", + "int:2": "float:380.37573281250025", + "int:3": "float:433.33333333333394", + "int:4": "float:53.96875", + "int:5": "float:-162.08333333333334", + "int:6": "float:0.0", + "int:7": "float:-2.7999999999992724", + "int:8": "float:-12.5", + "int:9": "float:-436.25" + }, + "relative": { + "int:1": "float:0.0034444444444445086", + "int:10": "float:0.000999999999999818", + "int:2": "float:0.04738961825968467", + "int:3": "float:0.07046070460704616", + "int:4": "float:0.007010351126446113", + "int:5": "float:-0.014171220400728598", + "int:6": "float:nan", + "int:7": "float:-0.00039999999999989604", + "int:8": "float:0.05", + "int:9": "float:-0.05" + } + }, + "detailed_budget": { + "income_tax": { + "baseline": "float:240000000000.0", + "difference": "float:1500000000.0", + "reform": "float:241500000000.0" + }, + "national_insurance": { + "baseline": "float:160000000000.0", + "difference": "float:-200000000.0", + "reform": "float:159800000000.0" + }, + "universal_credit": { + "baseline": "float:61000000000.0", + "difference": "float:750000000.0", + "reform": "float:61750000000.0" + } + }, + "inequality": { + "gini": { + "baseline": "float:0.4217", + "reform": "float:0.4186" + }, + "top_10_pct_share": { + "baseline": "float:0.3125", + "reform": "float:0.3083" + }, + "top_1_pct_share": { + "baseline": "float:0.1033", + "reform": "float:0.1022" + } + }, + "intra_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.07692307692307693", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.5571428571428572" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:1.0", + "float:1.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:1.0" + ] + } + } + }, + "intra_wealth_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.15384615384615385", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.4802197802197802" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.7692307692307693", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.0", + "float:0.0", + "float:1.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.23076923076923078", + "float:1.0" + ] + } + } + }, + "labor_supply_response": { + "decile": { + "average": { + "income": { + "float:-1.0": "float:3.625", + "float:1.0": "float:1.2083333333333333", + "float:10.0": "float:3.625", + "float:2.0": "float:-2.265625", + "float:3.0": "float:-1.2083333333333333", + "float:4.0": "float:2.265625", + "float:5.0": "float:-2.4166666666666665", + "float:6.0": "float:-3.625", + "float:7.0": "float:3.625", + "float:8.0": "float:0.0", + "float:9.0": "float:-3.625" + }, + "substitution": { + "float:-1.0": "float:-12.5", + "float:1.0": "float:-12.5", + "float:10.0": "float:12.5", + "float:2.0": "float:-6.25", + "float:3.0": "float:0.0", + "float:4.0": "float:6.25", + "float:5.0": "float:12.5", + "float:6.0": "float:-12.5", + "float:7.0": "float:-6.25", + "float:8.0": "float:0.0", + "float:9.0": "float:6.25" + } + }, + "relative": { + "income": { + "int:1": "float:3.598590841462219e-05", + "int:10": "float:0.00010826106596085429", + "int:2": "float:-7.336033330887989e-05", + "int:3": "float:-3.829348390155141e-05", + "int:4": "float:7.522873426357554e-05", + "int:5": "float:-6.1032201852432556e-05", + "int:6": "float:-0.00013174093372522475", + "int:7": "float:0.00011132394885240364", + "int:8": "float:0.0", + "int:9": "float:-0.0001018905976059223" + }, + "substitution": { + "int:1": "float:-0.00037226801808229855", + "int:10": "float:0.00037331402055466997", + "int:2": "float:-0.00020237333326587556", + "int:3": "float:0.0", + "int:4": "float:0.00020752754279607045", + "int:5": "float:0.000315683802684996", + "int:6": "float:-0.00045427908181111987", + "int:7": "float:-0.00019193784284897178", + "int:8": "float:0.0", + "int:9": "float:0.0001756734441481419" + } + } + }, + "hours": { + "baseline": "float:1180000000.0", + "change": "float:2300000.0", + "income_effect": "float:1000000.0", + "reform": "float:1182300000.0", + "substitution_effect": "float:-3000000.0" + }, + "income_lsr": "float:-75000000.0", + "relative_lsr": { + "income": "float64:5.8285236289352835e-06", + "substitution": "float64:-4.01967146823123e-05" + }, + "revenue_change": "float:400000000.0", + "substitution_lsr": "float:125000000.0", + "total_change": "float:50000000.0" + }, + "local_authority_impact": { + "by_local_authority": { + "Local authority 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Local authority 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "poverty": { + "deep_poverty": { + "adult": { + "baseline": "float:0.1346153846153846", + "reform": "float:0.1346153846153846" + }, + "all": { + "baseline": "float:0.1523809523809524", + "reform": "float:0.06666666666666667" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.0" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + }, + "poverty": { + "adult": { + "baseline": "float:0.40384615384615385", + "reform": "float:0.40384615384615385" + }, + "all": { + "baseline": "float:0.2857142857142857", + "reform": "float:0.37142857142857144" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.5625" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + } + }, + "poverty_by_gender": { + "deep_poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "male": { + "baseline": "float:0.3018867924528302", + "reform": "float:0.1320754716981132" + } + }, + "poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.09615384615384616" + }, + "male": { + "baseline": "float:0.5660377358490566", + "reform": "float:0.6415094339622641" + } + } + }, + "poverty_by_race": {}, + "wealth_decile": { + "average": { + "int:1": "float:-12.5", + "int:10": "float:-2.7999999999992724", + "int:2": "float:-436.25", + "int:3": "float:9.999999999998181", + "int:4": "float:26.09166666666715", + "int:5": "float:380.37573281250025", + "int:6": "float:433.33333333333394", + "int:7": "float:53.96875", + "int:8": "float:-162.08333333333334", + "int:9": "float:-200.0" + }, + "relative": { + "int:1": "float:0.05", + "int:10": "float:-0.00039999999999989604", + "int:2": "float:-0.05", + "int:3": "float:0.000999999999999818", + "int:4": "float:0.0034444444444445086", + "int:5": "float:0.04738961825968467", + "int:6": "float:0.07046070460704616", + "int:7": "float:0.007010351126446113", + "int:8": "float:-0.014171220400728598", + "int:9": "float:-0.02" + } + } + }, + "result_type": "dict" + }, + "a07d11f0be32b997": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact" + ], + "/budget": [ + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income" + ], + "/constituency_impact": [ + "by_constituency", + "outcomes_by_region" + ], + "/constituency_impact/by_constituency": [ + "Constituency 3" + ], + "/constituency_impact/by_constituency/Constituency 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/constituency_impact/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/constituency_impact/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/constituency_impact/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/decile": [ + "relative", + "average" + ], + "/decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/detailed_budget": [ + "income_tax", + "national_insurance", + "universal_credit" + ], + "/detailed_budget/income_tax": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/national_insurance": [ + "baseline", + "reform", + "difference" + ], + "/detailed_budget/universal_credit": [ + "baseline", + "reform", + "difference" + ], + "/inequality": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "/inequality/gini": [ + "baseline", + "reform" + ], + "/inequality/top_10_pct_share": [ + "baseline", + "reform" + ], + "/inequality/top_1_pct_share": [ + "baseline", + "reform" + ], + "/intra_decile": [ + "deciles", + "all" + ], + "/intra_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile": [ + "deciles", + "all" + ], + "/intra_wealth_decile/all": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/intra_wealth_decile/deciles": [ + "Lose more than 5%", + "Lose less than 5%", + "No change", + "Gain less than 5%", + "Gain more than 5%" + ], + "/labor_supply_response": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "/labor_supply_response/decile": [ + "average", + "relative" + ], + "/labor_supply_response/decile/average": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/average/income": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/average/substitution": [ + "float:-1.0", + "float:1.0", + "float:2.0", + "float:3.0", + "float:4.0", + "float:5.0", + "float:6.0", + "float:7.0", + "float:8.0", + "float:9.0", + "float:10.0" + ], + "/labor_supply_response/decile/relative": [ + "income", + "substitution" + ], + "/labor_supply_response/decile/relative/income": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/decile/relative/substitution": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/labor_supply_response/hours": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "/labor_supply_response/relative_lsr": [ + "income", + "substitution" + ], + "/poverty": [ + "poverty", + "deep_poverty" + ], + "/poverty/deep_poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/deep_poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/all": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/child": [ + "baseline", + "reform" + ], + "/poverty/deep_poverty/senior": [ + "baseline", + "reform" + ], + "/poverty/poverty": [ + "child", + "adult", + "senior", + "all" + ], + "/poverty/poverty/adult": [ + "baseline", + "reform" + ], + "/poverty/poverty/all": [ + "baseline", + "reform" + ], + "/poverty/poverty/child": [ + "baseline", + "reform" + ], + "/poverty/poverty/senior": [ + "baseline", + "reform" + ], + "/poverty_by_gender": [ + "poverty", + "deep_poverty" + ], + "/poverty_by_gender/deep_poverty": [ + "male", + "female" + ], + "/poverty_by_gender/deep_poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/deep_poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty": [ + "male", + "female" + ], + "/poverty_by_gender/poverty/female": [ + "baseline", + "reform" + ], + "/poverty_by_gender/poverty/male": [ + "baseline", + "reform" + ], + "/poverty_by_race": [], + "/wealth_decile": [ + "relative", + "average" + ], + "/wealth_decile/average": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ], + "/wealth_decile/relative": [ + "int:1", + "int:2", + "int:3", + "int:4", + "int:5", + "int:6", + "int:7", + "int:8", + "int:9", + "int:10" + ] + }, + "logs": [], + "result": { + "budget": { + "baseline_net_income": "float:1600000000000.0", + "benefit_spending_impact": "float:1750000000.0", + "budgetary_impact": "float:1500000000.0", + "households": "float:28.0", + "state_tax_revenue_impact": "float:-110000000.0", + "tax_revenue_impact": "float:3250000000.0" + }, + "constituency_impact": { + "by_constituency": { + "Constituency 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "decile": { + "average": { + "int:1": "float:26.09166666666715", + "int:10": "float:9.999999999998181", + "int:2": "float:380.37573281250025", + "int:3": "float:433.33333333333394", + "int:4": "float:53.96875", + "int:5": "float:-162.08333333333334", + "int:6": "float:0.0", + "int:7": "float:-2.7999999999992724", + "int:8": "float:-12.5", + "int:9": "float:-436.25" + }, + "relative": { + "int:1": "float:0.0034444444444445086", + "int:10": "float:0.000999999999999818", + "int:2": "float:0.04738961825968467", + "int:3": "float:0.07046070460704616", + "int:4": "float:0.007010351126446113", + "int:5": "float:-0.014171220400728598", + "int:6": "float:nan", + "int:7": "float:-0.00039999999999989604", + "int:8": "float:0.05", + "int:9": "float:-0.05" + } + }, + "detailed_budget": { + "income_tax": { + "baseline": "float:240000000000.0", + "difference": "float:1500000000.0", + "reform": "float:241500000000.0" + }, + "national_insurance": { + "baseline": "float:160000000000.0", + "difference": "float:-200000000.0", + "reform": "float:159800000000.0" + }, + "universal_credit": { + "baseline": "float:61000000000.0", + "difference": "float:750000000.0", + "reform": "float:61750000000.0" + } + }, + "inequality": { + "gini": { + "baseline": "float:0.4217", + "reform": "float:0.4186" + }, + "top_10_pct_share": { + "baseline": "float:0.3125", + "reform": "float:0.3083" + }, + "top_1_pct_share": { + "baseline": "float:0.1033", + "reform": "float:0.1022" + } + }, + "intra_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.07692307692307693", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.5571428571428572" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:1.0", + "float:1.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:1.0" + ] + } + } + }, + "intra_wealth_decile": { + "all": { + "Gain less than 5%": "float:0.046153846153846156", + "Gain more than 5%": "float:0.1054945054945055", + "Lose less than 5%": "float:0.15384615384615385", + "Lose more than 5%": "float:0.21428571428571427", + "No change": "float:0.4802197802197802" + }, + "deciles": { + "Gain less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.23076923076923078", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Gain more than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.7692307692307693", + "float:0.14285714285714285", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0" + ] + }, + "Lose less than 5%": { + "list": [ + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.7692307692307693", + "float:0.0", + "float:0.7692307692307693", + "float:0.0" + ] + }, + "Lose more than 5%": { + "list": [ + "float:1.0", + "float:1.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.0", + "float:0.14285714285714285", + "float:0.0", + "float:0.0" + ] + }, + "No change": { + "list": [ + "float:0.0", + "float:0.0", + "float:1.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.0", + "float:0.8571428571428571", + "float:0.23076923076923078", + "float:1.0" + ] + } + } + }, + "labor_supply_response": { + "decile": { + "average": { + "income": { + "float:-1.0": "float:3.625", + "float:1.0": "float:1.2083333333333333", + "float:10.0": "float:3.625", + "float:2.0": "float:-2.265625", + "float:3.0": "float:-1.2083333333333333", + "float:4.0": "float:2.265625", + "float:5.0": "float:-2.4166666666666665", + "float:6.0": "float:-3.625", + "float:7.0": "float:3.625", + "float:8.0": "float:0.0", + "float:9.0": "float:-3.625" + }, + "substitution": { + "float:-1.0": "float:-12.5", + "float:1.0": "float:-12.5", + "float:10.0": "float:12.5", + "float:2.0": "float:-6.25", + "float:3.0": "float:0.0", + "float:4.0": "float:6.25", + "float:5.0": "float:12.5", + "float:6.0": "float:-12.5", + "float:7.0": "float:-6.25", + "float:8.0": "float:0.0", + "float:9.0": "float:6.25" + } + }, + "relative": { + "income": { + "int:1": "float:3.598590841462219e-05", + "int:10": "float:0.00010826106596085429", + "int:2": "float:-7.336033330887989e-05", + "int:3": "float:-3.829348390155141e-05", + "int:4": "float:7.522873426357554e-05", + "int:5": "float:-6.1032201852432556e-05", + "int:6": "float:-0.00013174093372522475", + "int:7": "float:0.00011132394885240364", + "int:8": "float:0.0", + "int:9": "float:-0.0001018905976059223" + }, + "substitution": { + "int:1": "float:-0.00037226801808229855", + "int:10": "float:0.00037331402055466997", + "int:2": "float:-0.00020237333326587556", + "int:3": "float:0.0", + "int:4": "float:0.00020752754279607045", + "int:5": "float:0.000315683802684996", + "int:6": "float:-0.00045427908181111987", + "int:7": "float:-0.00019193784284897178", + "int:8": "float:0.0", + "int:9": "float:0.0001756734441481419" + } + } + }, + "hours": { + "baseline": "float:1180000000.0", + "change": "float:2300000.0", + "income_effect": "float:1000000.0", + "reform": "float:1182300000.0", + "substitution_effect": "float:-3000000.0" + }, + "income_lsr": "float:-75000000.0", + "relative_lsr": { + "income": "float64:5.8285236289352835e-06", + "substitution": "float64:-4.01967146823123e-05" + }, + "revenue_change": "float:400000000.0", + "substitution_lsr": "float:125000000.0", + "total_change": "float:50000000.0" + }, + "local_authority_impact": null, + "poverty": { + "deep_poverty": { + "adult": { + "baseline": "float:0.1346153846153846", + "reform": "float:0.1346153846153846" + }, + "all": { + "baseline": "float:0.1523809523809524", + "reform": "float:0.06666666666666667" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.0" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + }, + "poverty": { + "adult": { + "baseline": "float:0.40384615384615385", + "reform": "float:0.40384615384615385" + }, + "all": { + "baseline": "float:0.2857142857142857", + "reform": "float:0.37142857142857144" + }, + "child": { + "baseline": "float:0.28125", + "reform": "float:0.5625" + }, + "senior": { + "baseline": "float:0.0", + "reform": "float:0.0" + } + } + }, + "poverty_by_gender": { + "deep_poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.0" + }, + "male": { + "baseline": "float:0.3018867924528302", + "reform": "float:0.1320754716981132" + } + }, + "poverty": { + "female": { + "baseline": "float:0.0", + "reform": "float:0.09615384615384616" + }, + "male": { + "baseline": "float:0.5660377358490566", + "reform": "float:0.6415094339622641" + } + } + }, + "poverty_by_race": {}, + "wealth_decile": { + "average": { + "int:1": "float:-12.5", + "int:10": "float:-2.7999999999992724", + "int:2": "float:-436.25", + "int:3": "float:9.999999999998181", + "int:4": "float:26.09166666666715", + "int:5": "float:380.37573281250025", + "int:6": "float:433.33333333333394", + "int:7": "float:53.96875", + "int:8": "float:-162.08333333333334", + "int:9": "float:-200.0" + }, + "relative": { + "int:1": "float:0.05", + "int:10": "float:-0.00039999999999989604", + "int:2": "float:-0.05", + "int:3": "float:0.000999999999999818", + "int:4": "float:0.0034444444444445086", + "int:5": "float:0.04738961825968467", + "int:6": "float:0.07046070460704616", + "int:7": "float:0.007010351126446113", + "int:8": "float:-0.014171220400728598", + "int:9": "float:-0.02" + } + } + }, + "result_type": "dict" + }, + "a0f54bfeeaeb5f01": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 0", + "Local authority 1", + "Local authority 2", + "Local authority 3", + "Local authority 4", + "Local authority 5", + "Local authority 6", + "Local authority 7" + ], + "/by_local_authority/Local authority 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 3": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 5": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Local authority 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Local authority 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + }, + "Local authority 3": { + "average_household_income_change": "float:-80.0", + "relative_household_income_change": "float:-0.020000000000000018", + "x": "int:2", + "y": "int:1" + }, + "Local authority 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + }, + "Local authority 5": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:nan", + "x": "int:1", + "y": "int:5" + }, + "Local authority 6": { + "average_household_income_change": "float:189.81651617187526", + "relative_household_income_change": "float:0.029066292702476515", + "x": "int:4", + "y": "int:7" + }, + "Local authority 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:2", + "Lose less than 5%": "int:1", + "Lose more than 5%": "int:2", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "a22d74f249e80a14": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 0", + "Constituency 4" + ], + "/by_constituency/Constituency 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Constituency 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "b1c4dc99ea923286": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 7" + ], + "/by_local_authority/Local authority 7": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 7": { + "average_household_income_change": "float:-12.5", + "relative_household_income_change": "float:0.050000000000000044", + "x": "int:0", + "y": "int:9" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "b92910f1c70bc7be": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 6" + ], + "/by_constituency/Constituency 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 6": { + "average_household_income_change": "float:19.123372416666502", + "relative_household_income_change": "float:0.002298021920688953", + "x": "int:4", + "y": "int:7" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:1", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:1", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "bca9e3f160f4017e": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 4" + ], + "/by_constituency/Constituency 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "be770bdebb4bc44e": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 1", + "Constituency 6" + ], + "/by_constituency/Constituency 1": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_constituency/Constituency 6": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 1": { + "average_household_income_change": "float:37.25", + "relative_household_income_change": "float:0.020000000000000018", + "x": "int:3", + "y": "int:-3" + }, + "Constituency 6": { + "average_household_income_change": "float:19.123372416666502", + "relative_household_income_change": "float:0.002298021920688953", + "x": "int:4", + "y": "int:7" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:2", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "d51953975fb01911": { + "dataset_calls": [], + "key_order": { + "": [ + "baseline", + "reform" + ], + "/baseline": [ + "cliff_gap", + "cliff_share" + ], + "/reform": [ + "cliff_gap", + "cliff_share" + ] + }, + "logs": [], + "result": { + "baseline": { + "cliff_gap": "float:2062500000.0", + "cliff_share": "float:0.0457" + }, + "reform": { + "cliff_gap": "float:1875000000.0", + "cliff_share": "float:0.0421" + } + }, + "result_type": "dict" + }, + "d86811df62ca768c": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": {}, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + }, + "df9703bc15aed219": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data-private", + "local_authority_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data-private/local_authority_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "local_authorities_2021.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/local_authorities_2021.csv" + ] + ], + "key_order": { + "": [ + "by_local_authority", + "outcomes_by_region" + ], + "/by_local_authority": [ + "Local authority 0", + "Local authority 4" + ], + "/by_local_authority/Local authority 0": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/by_local_authority/Local authority 4": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_local_authority": { + "Local authority 0": { + "average_household_income_change": "float:100.0", + "relative_household_income_change": "float:0.10000000000000009", + "x": "int:0", + "y": "int:-5" + }, + "Local authority 4": { + "average_household_income_change": "float:-486.25", + "relative_household_income_change": "float:-0.09999999999999998", + "x": "int:5", + "y": "int:3" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:1", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:1", + "No change": "int:0" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + } + } + }, + "model": "UKLocalAuthorityBreakdown" + }, + "result_type": "UKLocalAuthorityBreakdown" + }, + "ea0cb06718c2a3ee": { + "dataset_calls": [ + [ + "download", + "policyengine/policyengine-uk-data", + "parliamentary_constituency_weights.h5" + ], + [ + "h5py.File", + "/fake/policyengine/policyengine-uk-data/parliamentary_constituency_weights.h5", + "r" + ], + [ + "download", + "policyengine/policyengine-uk-data-public", + "constituencies_2024.csv" + ], + [ + "read_csv", + "/fake/policyengine/policyengine-uk-data-public/constituencies_2024.csv" + ] + ], + "key_order": { + "": [ + "by_constituency", + "outcomes_by_region" + ], + "/by_constituency": [ + "Constituency 2" + ], + "/by_constituency/Constituency 2": [ + "average_household_income_change", + "relative_household_income_change", + "x", + "y" + ], + "/outcomes_by_region": [ + "uk", + "england", + "scotland", + "wales", + "northern_ireland" + ], + "/outcomes_by_region/england": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/northern_ireland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/scotland": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/uk": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ], + "/outcomes_by_region/wales": [ + "Gain more than 5%", + "Gain less than 5%", + "No change", + "Lose less than 5%", + "Lose more than 5%" + ] + }, + "logs": [], + "result": { + "fields": { + "by_constituency": { + "Constituency 2": { + "average_household_income_change": "float:0.0", + "relative_household_income_change": "float:0.0", + "x": "int:6", + "y": "int:-1" + } + }, + "outcomes_by_region": { + "england": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "northern_ireland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "scotland": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:0" + }, + "uk": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + }, + "wales": { + "Gain less than 5%": "int:0", + "Gain more than 5%": "int:0", + "Lose less than 5%": "int:0", + "Lose more than 5%": "int:0", + "No change": "int:1" + } + } + }, + "model": "UKConstituencyBreakdown" + }, + "result_type": "UKConstituencyBreakdown" + } + } +} diff --git a/tests/unit/runtime_cache/test_fake_compare_and_delete.py b/tests/unit/runtime_cache/test_fake_compare_and_delete.py new file mode 100644 index 000000000..312a222f0 --- /dev/null +++ b/tests/unit/runtime_cache/test_fake_compare_and_delete.py @@ -0,0 +1,34 @@ +"""Characterization of the in-memory fake's compare-and-delete script support.""" + +import pytest + +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend + + +def test_compare_and_delete_removes_only_a_matching_value(): + backend = InMemoryCacheBackend() + backend.set("claim", "owner") + + assert backend.eval("ignored script", 1, "claim", "contender") == 0 + assert backend.get("claim") == "owner" + assert backend.eval("ignored script", 1, "claim", "owner") == 1 + assert backend.get("claim") is None + assert backend.eval("ignored script", 1, "claim", "owner") == 0 + + +@pytest.mark.parametrize( + ("key_count", "keys_and_arguments"), + [ + (2, ("claim", "owner")), + (0, ("claim", "owner")), + (1, ("claim",)), + (1, ("claim", "owner", "extra")), + ], +) +def test_other_script_shapes_are_refused(key_count, keys_and_arguments): + backend = InMemoryCacheBackend() + + with pytest.raises( + ValueError, match="fake supports one-key compare-and-delete only" + ): + backend.eval("ignored script", key_count, *keys_and_arguments) diff --git a/tests/unit/runtime_cache/test_household_calculation_cache_reads.py b/tests/unit/runtime_cache/test_household_calculation_cache_reads.py new file mode 100644 index 000000000..1ae6715fb --- /dev/null +++ b/tests/unit/runtime_cache/test_household_calculation_cache_reads.py @@ -0,0 +1,150 @@ +"""Characterization of how a calculated-household cache read treats each entry.""" + +import pytest + +from policyengine_api.runtime_cache.core import CacheNamespace, encode_envelope +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.household_calculations import ( + HOUSEHOLD_CALCULATION_SCHEMA_VERSION, + CachedHouseholdCalculation, + HouseholdCalculationCache, + HouseholdCalculationIdentity, +) + +SPM_CONFIG = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +SPM_RECEIPT = { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "test-artifact", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, + "years": ["2026"], +} +HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} + + +def _identity(spm: dict | None) -> HouseholdCalculationIdentity: + return HouseholdCalculationIdentity( + country_id="us", + household_id=1, + policy_id=2, + household_hash="household-a", + policy_hash="policy-a", + country_package_version="1.2.3", + policyengine_version="4.5.6", + spm=spm, + ) + + +def _read_stored(payload: object, spm: dict | None) -> object: + backend = InMemoryCacheBackend() + cache = HouseholdCalculationCache(backend, CacheNamespace("test", "api")) + identity = _identity(spm) + backend.set( + cache.cache_key(identity), + encode_envelope( + "household-calculation", + HOUSEHOLD_CALCULATION_SCHEMA_VERSION, + payload, + ), + ) + return cache.get(identity) + + +@pytest.mark.parametrize( + ("payload", "spm"), + [ + pytest.param(["not", "an", "object"], None, id="payload-not-object"), + pytest.param({"household": [], "warnings": []}, None, id="household-list"), + pytest.param({"household": HOUSEHOLD, "warnings": "x"}, None, id="warnings"), + pytest.param({"household": HOUSEHOLD}, None, id="warnings-missing"), + pytest.param( + {"household": HOUSEHOLD, "warnings": ["ok", 3]}, None, id="warning-int" + ), + pytest.param( + {"household": HOUSEHOLD, "warnings": [], "spm_config": SPM_CONFIG}, + None, + id="legacy-spm-config", + ), + pytest.param( + {"household": HOUSEHOLD, "warnings": [], "spm_provenance": "receipt"}, + None, + id="receipt-not-object", + ), + pytest.param( + {"household": HOUSEHOLD, "warnings": [], "spm_provenance": SPM_RECEIPT}, + None, + id="receipt-without-requested-spm", + ), + pytest.param( + {"household": HOUSEHOLD, "warnings": []}, SPM_CONFIG, id="receipt-missing" + ), + pytest.param( + { + "household": HOUSEHOLD, + "warnings": [], + "spm_provenance": {**SPM_RECEIPT, "years": "2026"}, + }, + SPM_CONFIG, + id="receipt-invalid", + ), + pytest.param( + {"household": HOUSEHOLD, "warnings": [], "spm_provenance": SPM_RECEIPT}, + {**SPM_CONFIG, "scenario": "other"}, + id="receipt-for-other-selection", + ), + ], +) +def test_malformed_or_mismatched_entries_read_as_misses(payload, spm): + assert _read_stored(payload, spm) is None + + +def test_entry_without_spm_reads_back_with_warnings_as_a_tuple(): + cached = _read_stored( + {"household": HOUSEHOLD, "warnings": ["first", "second"]}, + None, + ) + + assert cached == CachedHouseholdCalculation( + household=HOUSEHOLD, + warnings=("first", "second"), + spm_provenance=None, + ) + + +def test_entry_with_explicit_null_receipt_and_no_spm_is_a_hit(): + cached = _read_stored( + {"household": HOUSEHOLD, "warnings": [], "spm_provenance": None}, + None, + ) + + assert cached == CachedHouseholdCalculation(household=HOUSEHOLD) + + +def test_matching_receipt_reads_back_in_its_canonical_form(): + cached = _read_stored( + {"household": HOUSEHOLD, "warnings": ["w"], "spm_provenance": SPM_RECEIPT}, + SPM_CONFIG, + ) + + assert cached == CachedHouseholdCalculation( + household=HOUSEHOLD, + warnings=("w",), + spm_provenance=SPM_RECEIPT, + ) diff --git a/tests/unit/runtime_cache/test_runtime_cache_contracts.py b/tests/unit/runtime_cache/test_runtime_cache_contracts.py new file mode 100644 index 000000000..ef291b9ca --- /dev/null +++ b/tests/unit/runtime_cache/test_runtime_cache_contracts.py @@ -0,0 +1,383 @@ +"""Characterization of the shared-cache keys and how request fields reach them. + +Old and new deployments share these Redis keys, so each key string is pinned +exactly, along with the stored reform-impact records and claim errors. +""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from policyengine_api.runtime_cache.core import CacheCoordinationError, CacheNamespace +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.reform_impacts import ( + CachedReformImpact, + ReformImpactCache, + reform_impact_id, +) +from policyengine_api.services.budget_window_cache import BudgetWindowCache +from policyengine_api.services.reform_impacts_service import ReformImpactsService + +NAMESPACE = CacheNamespace("test", "api") +OBSERVABILITY_ID = "00000000-0000-4000-8000-000000000001" +CLAIM_TOKEN = "owner" # noqa: S105 # an opaque claim id, not a secret +SCOPE = { + "country_id": "us", + "reform_policy_id": 22, + "baseline_policy_id": 11, + "region": "state/ca", + "dataset": "default", + "time_period": "2026", +} +START_CLAIM_KEY = "policyengine:test:api:reform-impact-start-claim:v3:" +BUDGET_WINDOW_KEY = "policyengine:test:api:budget-window:v4:" + + +def _cache() -> ReformImpactCache: + return ReformImpactCache(InMemoryCacheBackend(), NAMESPACE) + + +def _service() -> ReformImpactsService: + return ReformImpactsService(_cache()) + + +def _claim_arguments(**changes: object) -> dict: + return { + **SCOPE, + "api_version": "v7", + "options_hash": "[region=state/ca&dataset=default]", + "target": "general", + **changes, + } + + +def test_start_claim_key_is_stable(): + key = _cache()._start_claim_key(**_claim_arguments()) + + assert key == ( + START_CLAIM_KEY + + "19e29b251efb3cde28069f9da9d733511630bb7af744423e0347a5ce4a960b4b" + ) + + +@pytest.mark.parametrize( + "changes", + [ + {"country_id": "uk"}, + {"reform_policy_id": 23}, + {"baseline_policy_id": 12}, + {"region": "us"}, + {"dataset": "other"}, + {"time_period": "2027"}, + {"api_version": "v8"}, + {"options_hash": "[other]"}, + {"target": "cliff"}, + ], +) +def test_every_scope_field_separates_start_claims(changes): + cache = _cache() + + assert cache._start_claim_key(**_claim_arguments(**changes)) != ( + cache._start_claim_key(**_claim_arguments()) + ) + + +def test_budget_window_key_is_stable(): + cache = BudgetWindowCache(InMemoryCacheBackend(), NAMESPACE) + + key = cache.build_key( + **SCOPE, + options_hash="[region=state/ca&dataset=default]", + api_version="v7", + ) + + assert key == ( + BUDGET_WINDOW_KEY + + "b7bf1813d90cf73b85ed9614c3a88a8f4b6b94569b2fd7d39a2fa929aac6f76a" + ) + + +def test_record_and_index_keys_are_stable(): + cache = _cache() + impact = _stored_impact() + + assert cache._record_key("job-1") == ( + "policyengine:test:api:reform-impact:v3:" + "1bec8b51d89bb67dc68433bf3cee8119e14f3b329f0a489b589e1129524cc0f7" + ) + assert cache._scope_index(impact) == ( + "policyengine:test:api:reform-impact-index:v3:" + "3a5bb6a4515c5bfba4138087969e22b67cbdfbbccaf4e2facec408e341da61f1" + ) + assert cache._recent_index() == ( + "policyengine:test:api:reform-impact-index:v3:recent" + ) + + +def _stored_impact(**changes: object) -> CachedReformImpact: + values = { + "reform_impact_id": reform_impact_id("job-1"), + "baseline_policy_id": 11, + "reform_policy_id": 22, + "country_id": "us", + "region": "state/ca", + "dataset": "default", + "time_period": "2026", + "options_json": {"region": "state/ca"}, + "options_hash": "[region=state/ca&dataset=default]", + "api_version": "v7", + "reform_impact_json": {}, + "status": "computing", + "message": None, + "start_time": datetime(2026, 1, 2, 3, 4, 5), # noqa: DTZ001 # reform_impact start_time is a naive column + "end_time": None, + "execution_id": "job-1", + "error_code": None, + "observability_id": OBSERVABILITY_ID, + } + values.update(changes) + return CachedReformImpact(**values) + + +def test_set_reform_impact_stores_every_field_from_its_arguments(): + service = _service() + + stored = service.set_reform_impact( + country_id="us", + policy_id=22, + baseline_policy_id=11, + region="state/ca", + dataset="default", + time_period="2026", + options={"region": "state/ca"}, + options_hash="[region=state/ca&dataset=default]", + status="computing", + api_version="v7", + reform_impact_json={}, + start_time=datetime(2026, 1, 2, 3, 4, 5), # noqa: DTZ001 # reform_impact start_time is a naive column + execution_id="job-1", + observability_id=OBSERVABILITY_ID, + ) + + assert stored == _stored_impact() + assert service.get_recent_reform_impacts(5) == [_stored_impact()] + + +def test_lookups_select_by_every_scope_field(): + service = _service() + service._cache.set(_stored_impact()) + + exact = service.get_all_reform_impacts( + "us", + 22, + 11, + "state/ca", + "default", + "2026", + "[region=state/ca&dataset=default]", + "v7", + ) + by_prefix = service.get_all_reform_impacts_by_options_hash_prefix( + "us", + 22, + 11, + "state/ca", + "default", + "2026", + "[region=state/ca]", + "[region=state/ca&%", + "v7", + ) + swapped_policies = service.get_all_reform_impacts( + "us", + 11, + 22, + "state/ca", + "default", + "2026", + "[region=state/ca&dataset=default]", + "v7", + ) + + assert exact == [_stored_impact()] + assert by_prefix == [_stored_impact()] + assert swapped_policies == [] + + +def test_transitions_update_only_the_execution_record(): + service = _service() + service._cache.set(_stored_impact()) + service._cache.set(_stored_impact(execution_id="job-2", options_hash="[two]")) + + completed = service.set_complete_reform_impact( + "uk", + 1, + 2, + "uk", + "ignored", + "1999", + "[ignored]", + {"budget": 1}, + "job-1", + ) + failed = service.set_error_reform_impact( + "uk", + 1, + 2, + "uk", + "ignored", + "1999", + "[ignored]", + "worker failed", + "job-2", + error_code="SPM_CONFIGURATION_UNAVAILABLE", + ) + + assert completed == _stored_impact( + status="ok", + message="Completed", + reform_impact_json={"budget": 1}, + end_time=completed.end_time, + ) + assert completed.end_time is not None + assert failed == _stored_impact( + execution_id=None, + options_hash="[two]", + status="error", + message="worker failed", + error_code="SPM_CONFIGURATION_UNAVAILABLE", + end_time=failed.end_time, + ) + + +@pytest.mark.parametrize( + "changes", + [ + {"country_id": "uk"}, + {"reform_policy_id": 23}, + {"baseline_policy_id": 12}, + {"region": "us"}, + {"dataset": "other"}, + {"time_period": "2027"}, + {"options_hash": "[other]"}, + {"status": "ok"}, + ], +) +def test_delete_keeps_records_differing_in_any_field(changes): + service = _service() + service._cache.set(_stored_impact(**changes)) + + service.delete_reform_impact( + "us", + 22, + 11, + "state/ca", + "default", + "2026", + "[region=state/ca&dataset=default]", + ) + + assert len(service.get_recent_reform_impacts(5)) == 1 + + +def test_delete_ignores_the_api_version(): + service = _service() + service._cache.set(_stored_impact(api_version="v0")) + + service.delete_reform_impact( + "us", + 22, + 11, + "state/ca", + "default", + "2026", + "[region=state/ca&dataset=default]", + ) + + assert service.get_recent_reform_impacts(5) == [] + + +def test_delete_swallows_a_failed_cache_write(): + backend = MagicMock(wraps=InMemoryCacheBackend()) + cache = ReformImpactCache(backend, NAMESPACE) + cache.set(_stored_impact()) + backend.pipeline.side_effect = RuntimeError("redis unavailable") + + ReformImpactsService(cache).delete_reform_impact( + "us", + 22, + 11, + "state/ca", + "default", + "2026", + "[region=state/ca&dataset=default]", + ) + + +@pytest.mark.parametrize( + ("claim_token", "observability_id"), + [("", OBSERVABILITY_ID), (CLAIM_TOKEN, "not-an-identifier")], +) +def test_claims_require_a_token_and_valid_identifier(claim_token, observability_id): + cache = _cache() + message = "a claim token and valid observability identifier are required" + + with pytest.raises(ValueError, match=message): + cache.claim_start( + **_claim_arguments(), + claim_token=claim_token, + observability_id=observability_id, + ) + with pytest.raises(ValueError, match=message): + cache.release_start( + **_claim_arguments(), + claim_token=claim_token, + observability_id=observability_id, + ) + + +def test_unreachable_claim_store_fails_closed(): + backend = MagicMock() + backend.get.side_effect = RuntimeError("redis unavailable") + cache = ReformImpactCache(backend, NAMESPACE) + + with pytest.raises( + CacheCoordinationError, + match="reform-impact submission ownership is unavailable", + ) as caught: + cache.get_start_claim(**_claim_arguments()) + + assert type(caught.value.__cause__) is RuntimeError + + +def test_service_claim_round_trip_uses_the_same_key(): + service = _service() + arguments = { + "country_id": "us", + "policy_id": 22, + "baseline_policy_id": 11, + "region": "state/ca", + "dataset": "default", + "time_period": "2026", + "options_hash": "[region=state/ca&dataset=default]", + "api_version": "v7", + "target": "general", + } + + assert service.claim_reform_impact_start( + **arguments, claim_token=CLAIM_TOKEN, observability_id=OBSERVABILITY_ID + ) + claim = service.get_reform_impact_start_claim(**arguments) + stored = service._cache.client.get( + service._cache._start_claim_key(**_claim_arguments()) + ) + service.release_reform_impact_start( + **arguments, claim_token=CLAIM_TOKEN, observability_id=OBSERVABILITY_ID + ) + + assert claim is not None + assert claim.submission_claim_id == CLAIM_TOKEN + assert claim.observability_id == OBSERVABILITY_ID + assert stored is not None + assert service.get_reform_impact_start_claim(**arguments) is None diff --git a/tests/unit/services/test_economy_comparison_snapshots.py b/tests/unit/services/test_economy_comparison_snapshots.py new file mode 100644 index 000000000..93aeb713a --- /dev/null +++ b/tests/unit/services/test_economy_comparison_snapshots.py @@ -0,0 +1,534 @@ +"""Characterization snapshots of the published economy comparison outputs. + +``compare_economic_outputs`` and the UK area breakdowns build the economy impact +results the Public API returns and caches, so these tests pin their complete +output: every key in insertion order, every value with its type, and every float +bit for bit (by ``repr``). The inputs exercise the UK and US paths and each +branch of the comparison: budgets, deciles, poverty, inequality, labour supply, +wealth deciles (including their logged failure), cliffs, and the constituency and +local-authority breakdowns with every region filter and income-change bucket. + +The expected values live in ``tests/data/economy_comparison_snapshots.json``. To +regenerate them after an intended change to the published output, run this module +with ``ECONOMY_COMPARISON_SNAPSHOTS=update`` and review the diff. +""" + +import hashlib +import json +import logging +import math +import os +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import numpy as np +import pandas as pd +import pytest +from pydantic import BaseModel + +from policyengine_api.services import economy_comparison +from policyengine_api.services.economy_comparison import ( + compare_economic_outputs, + uk_constituency_breakdown, + uk_local_authority_breakdown, +) + +SNAPSHOT_PATH = ( + Path(__file__).resolve().parents[2] / "data" / "economy_comparison_snapshots.json" +) +UPDATE_SNAPSHOTS = os.environ.get("ECONOMY_COMPARISON_SNAPSHOTS") == "update" + +HOUSEHOLD_COUNT = 16 +PERSON_COUNT = 18 + +# Reform-to-baseline income ratios chosen to land households (and areas made of +# one household) in each income-change bucket, on and around its boundaries. +INCOME_CHANGE_FACTORS = [ + 1.1, + 1.02, + 1.0, + 0.98, + 0.9, + 1.0004, + 0.9996, + 1.05, + 0.95, + 1.001, + 0.999, + 1.0500001, +] + +# One area per nation prefix and income-change bucket, one with an unknown prefix +# and a zero-income household (a NaN relative change), and a blended area. +AREA_CODES = [ + "E14000530", + "S14000001", + "W07000041", + "N06000001", + "E14000531", + "K99999999", + "S14000002", + "N06000002", +] + + +def _household_values(build: Callable[[int], Any]) -> list: + return [build(index) for index in range(HOUSEHOLD_COUNT)] + + +def _person_values(build: Callable[[int], Any]) -> list: + return [build(index) for index in range(PERSON_COUNT)] + + +def _baseline_incomes() -> list[float]: + incomes = _household_values( + lambda index: 1000.0 * (index + 1) - (index % 3) * 137.5 + ) + incomes[5] = 0.0 + incomes[7] = -250.0 + return incomes + + +def _reform_incomes() -> list[float]: + return [ + income * INCOME_CHANGE_FACTORS[index % len(INCOME_CHANGE_FACTORS)] + for index, income in enumerate(_baseline_incomes()) + ] + + +def _income_deciles() -> list[float]: + deciles = _household_values(lambda index: float(index % 10 + 1)) + deciles[15] = -1.0 + return deciles + + +def _economy( + *, + country_id: str, + side: str, + include_wealth: bool, +) -> dict[str, Any]: + """Build one side of a general economy with household and person arrays.""" + is_reform = side == "reform" + shift = 1 if is_reform else 0 + economy: dict[str, Any] = { + "type": "general", + "total_tax": 4.2e11 + shift * 3.25e9, + "total_state_tax": 7.5e10 - shift * 1.1e8, + "total_benefits": 2.9e11 + shift * 1.75e9, + "total_net_income": 1.6e12 + shift * 2.5e9, + "gini": 0.4217 - shift * 0.0031, + "top_10_percent_share": 0.3125 - shift * 0.0042, + "top_1_percent_share": 0.1033 - shift * 0.0011, + "substitution_lsr": 1.25e8 * shift - 3.0e7, + "income_lsr": -7.5e7 * shift + 1.0e7, + "budgetary_impact_lsr": 4.0e8 * shift + 2.5e7, + "weekly_hours": 1.18e9 + shift * 2.3e6, + "weekly_hours_income_effect": 1.0e6 * shift - 2.0e5, + "weekly_hours_substitution_effect": -3.0e6 * shift + 4.0e5, + "household_net_income": _reform_incomes() if is_reform else _baseline_incomes(), + "household_weight": _household_values(lambda index: 1.0 + (index % 4) * 0.5), + "household_income_decile": _income_deciles(), + "household_count_people": _household_values(lambda index: float(index % 4 + 1)), + "substitution_lsr_hh": _household_values( + lambda index: (index % 5 - 2) * 12.5 * (1.5 if is_reform else 1.0) + ), + "income_lsr_hh": _household_values( + lambda index: (index % 3 - 1) * 7.25 * (0.5 if is_reform else 1.0) + ), + "employment_income_hh": _household_values( + lambda index: 20000.0 + index * 1500.0 + ), + "self_employment_income_hh": _household_values( + lambda index: 0.0 if index % 2 else 3500.0 + index * 10.0 + ), + "person_in_poverty": _person_values( + lambda index: (index % 4 == 0) or (is_reform and index % 9 == 1) + ), + "person_in_deep_poverty": _person_values( + lambda index: index % 8 == 0 and not (is_reform and index % 16 == 0) + ), + "person_weight": _person_values(lambda index: 1.0 + (index % 5) * 0.25), + "age": _person_values( + lambda index: [3, 17, 18, 30, 64, 65, 80, 12, 45][index % 9] + ), + "is_male": _person_values(lambda index: index % 2 == 0), + "race": ( + _person_values( + lambda index: ["WHITE", "BLACK", "HISPANIC", "OTHER"][index % 4] + ) + if country_id == "us" + else None + ), + } + if country_id == "uk": + economy["programs"] = { + "income_tax": 2.4e11 + shift * 1.5e9, + "national_insurance": 1.6e11 - shift * 2.0e8, + "universal_credit": 6.1e10 + shift * 7.5e8, + } + if include_wealth: + economy["household_wealth_decile"] = _household_values( + lambda index: float((index + 3) % 10 + 1) + ) + return economy + + +def _numpy_economy(economy: dict[str, Any]) -> dict[str, Any]: + """Return the economy with every list replaced by a numpy array.""" + return { + key: np.array(value) if isinstance(value, list) else value + for key, value in economy.items() + } + + +def _cliff_economy(*, side: str) -> dict[str, Any]: + is_reform = side == "reform" + return { + "type": "cliff", + "cliff_gap": 1.875e9 if is_reform else 2.0625e9, + "cliff_share": 0.0421 if is_reform else 0.0457, + } + + +def _area_names(prefix: str) -> pd.DataFrame: + """Return an area table (code, name, x, y) in the shape of the UK datasets.""" + return pd.DataFrame( + { + "code": AREA_CODES, + "name": [f"{prefix} {index}" for index in range(len(AREA_CODES))], + "x": [float(index * 3 % 7) for index in range(len(AREA_CODES))], + "y": [index * 2 - 5 for index in range(len(AREA_CODES))], + } + ) + + +def _area_weights(seed_offset: int) -> np.ndarray: + """Return area-by-household weights: one household per area, then blends.""" + weights = np.zeros((len(AREA_CODES), HOUSEHOLD_COUNT)) + for area_index in range(len(AREA_CODES)): + weights[area_index, area_index] = 1.0 + weights[6] = [ + ((index + seed_offset) % 5) * 0.75 for index in range(HOUSEHOLD_COUNT) + ] + weights[7, 7] = 2.0 + return weights + + +class FakeUKDatasets: + """Stand in for the Hugging Face downloads, the HDF5 weights and the CSVs.""" + + def __init__(self) -> None: + self.calls: list[list[Any]] = [] + self._weights = { + "parliamentary_constituency_weights.h5": _area_weights(0), + "local_authority_weights.h5": _area_weights(2), + } + self._names = { + "constituencies_2024.csv": _area_names("Constituency"), + "local_authorities_2021.csv": _area_names("Local authority"), + } + + def download(self, *, repo: str, repo_filename: str) -> str: + self.calls.append(["download", repo, repo_filename]) + return f"/fake/{repo}/{repo_filename}" + + def open_weights(self, path: str, mode: str) -> "FakeWeightsFile": + self.calls.append(["h5py.File", path, mode]) + return FakeWeightsFile({"2025": self._weights[Path(path).name]}) + + def read_names(self, path: str) -> pd.DataFrame: + self.calls.append(["read_csv", path]) + return self._names[Path(path).name].copy() + + +class FakeWeightsFile: + """A context manager that yields a mapping like an open ``h5py.File``.""" + + def __init__(self, datasets: dict[str, np.ndarray]) -> None: + self._datasets = datasets + + def __enter__(self) -> dict[str, np.ndarray]: + return self._datasets + + def __exit__(self, *_exc_info: object) -> bool: + return False + + +@pytest.fixture +def uk_datasets() -> Iterator[FakeUKDatasets]: + datasets = FakeUKDatasets() + with ( + patch.object( + economy_comparison, "download_huggingface_dataset", datasets.download + ), + patch.object(economy_comparison.h5py, "File", datasets.open_weights), + patch.object(economy_comparison.pd, "read_csv", datasets.read_names), + ): + yield datasets + + +def _canonical_key(key: object) -> str: + """Keep string keys as they are and tag any other key with its type.""" + if isinstance(key, str): + return key + return f"{type(key).__name__}:{key!r}" + + +def _canonical(value: object) -> object: + """Return a JSON-safe form of ``value`` that keeps every type and float bit.""" + if isinstance(value, BaseModel): + return { + "model": type(value).__name__, + "fields": _canonical(value.model_dump()), + } + if isinstance(value, dict): + return {_canonical_key(key): _canonical(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return { + type(value).__name__: [_canonical(item) for item in value], + } + if value is None or isinstance(value, str | bool): + return value + if isinstance(value, float | np.floating): + return f"{type(value).__name__}:{float(value)!r}" + if isinstance(value, int | np.integer | np.bool_): + return f"{type(value).__name__}:{value!r}" + raise TypeError(type(value).__name__) + + +def _key_order(value: object, path: str = "") -> dict[str, list[str]]: + """Map each dict's path to its keys in insertion order, so reorders show up.""" + if isinstance(value, BaseModel): + return _key_order(value.model_dump(), path) + orders: dict[str, list[str]] = {} + if isinstance(value, dict): + orders[path] = [_canonical_key(key) for key in value] + for key, item in value.items(): + orders.update(_key_order(item, f"{path}/{_canonical_key(key)}")) + elif isinstance(value, list | tuple): + for index, item in enumerate(value): + orders.update(_key_order(item, f"{path}[{index}]")) + return orders + + +def _observed( + result: object, + datasets: FakeUKDatasets | None, + caplog: pytest.LogCaptureFixture, +) -> dict[str, Any]: + return { + "result_type": type(result).__name__, + "result": _canonical(result), + "key_order": _key_order(result), + "dataset_calls": datasets.calls if datasets is not None else [], + "logs": [ + [ + record.name, + record.levelname, + record.getMessage(), + record.exc_info[0].__name__ if record.exc_info else None, + ] + for record in caplog.records + if record.name.startswith("policyengine_api") + ], + } + + +def _load_snapshots() -> dict[str, Any]: + if not SNAPSHOT_PATH.exists(): + return {"cases": {}, "outputs": {}} + return json.loads(SNAPSHOT_PATH.read_text(encoding="utf-8")) + + +def _write_snapshot(case_name: str, observed: dict[str, Any], output_id: str) -> None: + """Record a case, sharing one stored output between cases that agree.""" + snapshots = _load_snapshots() + snapshots["cases"][case_name] = output_id + snapshots["outputs"][output_id] = observed + used_outputs = set(snapshots["cases"].values()) + snapshots["outputs"] = { + stored_id: stored + for stored_id, stored in snapshots["outputs"].items() + if stored_id in used_outputs + } + SNAPSHOT_PATH.write_text( + json.dumps(snapshots, sort_keys=True, indent=1) + "\n", + encoding="utf-8", + ) + + +def _assert_matches_snapshot(case_name: str, observed: dict[str, Any]) -> None: + serialized = json.dumps(observed, sort_keys=True) + if UPDATE_SNAPSHOTS: + output_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16] + _write_snapshot(case_name, json.loads(serialized), output_id) + return + snapshots = _load_snapshots() + assert case_name in snapshots["cases"], f"no snapshot recorded for {case_name}" + expected = snapshots["outputs"][snapshots["cases"][case_name]] + assert json.loads(serialized) == expected + assert serialized == json.dumps(expected, sort_keys=True) + + +GENERAL_CASES = { + "uk_general_all_regions": ("uk", None, True, False), + "uk_general_country_scotland": ("uk", "country/scotland", True, False), + "uk_general_constituency_by_name": ( + "uk", + "constituency/Constituency 3", + True, + False, + ), + "uk_general_numpy_inputs": ("uk", "country/northern_ireland", True, True), + "us_general_national_without_wealth_deciles": ("us", "us", False, False), + "us_general_state_with_wealth_deciles": ("us", "state/ca", True, False), +} + + +@pytest.mark.parametrize("case_name", sorted(GENERAL_CASES)) +def test_compare_economic_outputs_general_matches_snapshot( + case_name, uk_datasets, caplog +): + country_id, region, include_wealth, use_numpy = GENERAL_CASES[case_name] + economy_country = country_id or "us" + baseline = _economy( + country_id=economy_country, side="baseline", include_wealth=include_wealth + ) + reform = _economy( + country_id=economy_country, side="reform", include_wealth=include_wealth + ) + if use_numpy: + baseline, reform = _numpy_economy(baseline), _numpy_economy(reform) + caplog.set_level(logging.DEBUG) + + result = compare_economic_outputs(baseline, reform, country_id, region) + + _assert_matches_snapshot(case_name, _observed(result, uk_datasets, caplog)) + + +@pytest.mark.parametrize( + ("case_name", "baseline_type", "country_id"), + [ + ("uk_cliff", "cliff", "uk"), + ("us_cliff", "cliff", "us"), + ("unknown_type", "household", "us"), + ("missing_type", None, "uk"), + ], +) +def test_compare_economic_outputs_other_types_match_snapshot( + case_name, baseline_type, country_id, caplog +): + baseline = _cliff_economy(side="baseline") + reform = _cliff_economy(side="reform") + if baseline_type is None: + del baseline["type"] + else: + baseline["type"] = baseline_type + caplog.set_level(logging.DEBUG) + + result = compare_economic_outputs(baseline, reform, country_id, None) + + _assert_matches_snapshot(case_name, _observed(result, None, caplog)) + + +BREAKDOWN_REGIONS = [ + None, + "uk", + "country/england", + "country/scotland", + "country/wales", + "country/northern_ireland", + "country/ENGLAND", + "country/Northern_Ireland", + "country/unknown", + "country/", + "constituency/Constituency 4", + f"constituency/{AREA_CODES[6]}", + "constituency/Missing", + "local_authority/Local authority 4", + f"local_authority/{AREA_CODES[7]}", + "local_authority/Missing", + "state/ca", +] + + +def _uk_breakdown_economies() -> tuple[dict[str, Any], dict[str, Any]]: + baseline = {"household_net_income": _baseline_incomes()} + reform = {"household_net_income": _reform_incomes()} + return baseline, reform + + +@pytest.mark.parametrize( + ("breakdown_name", "breakdown"), + [ + ("constituency", uk_constituency_breakdown), + ("local_authority", uk_local_authority_breakdown), + ], +) +@pytest.mark.parametrize("region", BREAKDOWN_REGIONS) +def test_uk_area_breakdowns_match_snapshot( + breakdown_name, breakdown, region, uk_datasets, caplog +): + baseline, reform = _uk_breakdown_economies() + caplog.set_level(logging.DEBUG) + + result = breakdown(baseline, reform, "uk", region) + + _assert_matches_snapshot( + f"{breakdown_name}_breakdown_region_{region}", + _observed(result, uk_datasets, caplog), + ) + + +@pytest.mark.parametrize( + ("breakdown_name", "breakdown"), + [ + ("constituency", uk_constituency_breakdown), + ("local_authority", uk_local_authority_breakdown), + ], +) +@pytest.mark.parametrize("country_id", ["us", None, "UK"]) +def test_uk_area_breakdowns_outside_uk_match_snapshot( + breakdown_name, breakdown, country_id, uk_datasets, caplog +): + baseline, reform = _uk_breakdown_economies() + + result = breakdown(baseline, reform, country_id, None) + + _assert_matches_snapshot( + f"{breakdown_name}_breakdown_country_{country_id}", + _observed(result, uk_datasets, caplog), + ) + + +def test_uk_area_breakdowns_with_numpy_incomes_match_snapshot(uk_datasets, caplog): + baseline, reform = _uk_breakdown_economies() + baseline = _numpy_economy(baseline) + reform = _numpy_economy(reform) + + results = { + "constituency": uk_constituency_breakdown(baseline, reform, "uk"), + "local_authority": uk_local_authority_breakdown(baseline, reform, "uk"), + } + + _assert_matches_snapshot( + "area_breakdowns_numpy_incomes", _observed(results, uk_datasets, caplog) + ) + + +def test_snapshot_inputs_reach_every_income_change_bucket(uk_datasets): + """Guard the inputs: each bucket and a NaN area change must stay exercised.""" + baseline, reform = _uk_breakdown_economies() + + result = uk_constituency_breakdown(baseline, reform, "uk") + + assert result is not None + assert all(count > 0 for count in result.outcomes_by_region["uk"].values()) + assert any( + math.isnan(area.relative_household_income_change) + for area in result.by_constituency.values() + ) + assert uk_datasets.calls diff --git a/tests/unit/services/test_economy_service_request_plumbing.py b/tests/unit/services/test_economy_service_request_plumbing.py new file mode 100644 index 000000000..5028dbe1b --- /dev/null +++ b/tests/unit/services/test_economy_service_request_plumbing.py @@ -0,0 +1,420 @@ +"""Characterization of how economy requests reach setup, caches and simulations. + +These pin the complete resolved setup options, the cache records and keys, and +the simulation payloads, so a change in how request fields are passed between +the service's steps shows up as a difference here. +""" + +import json +from unittest.mock import patch + +import pytest + +from policyengine_api.runtime_cache.core import CacheNamespace +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.reform_impacts import ( + CachedReformImpact, + ReformImpactCache, + reform_impact_id, +) +from policyengine_api.services.budget_window_cache import BudgetWindowCache +from policyengine_api.services.economy_service import ( + EconomicImpactResult, + EconomyService, +) +from policyengine_api.services.reform_impacts_service import ReformImpactsService +from tests.fixtures.services.economy_service import ( + MOCK_API_VERSION, + MOCK_BASELINE_POLICY_ID, + MOCK_LOOKUP_OPTIONS_HASH, + MOCK_MODAL_JOB_ID, + MOCK_MODEL_VERSION, + MOCK_OBSERVABILITY_ID, + MOCK_OPTIONS, + MOCK_OPTIONS_HASH, + MOCK_POLICY_ID, + MOCK_POLICYENGINE_VERSION, + MOCK_RESOLVED_APP_NAME, + MOCK_SUBMISSION_CLAIM_ID, + create_mock_budget_window_batch_execution, +) +from tests.fixtures.spm import resolved_options + +pytest_plugins = ("tests.fixtures.services.economy_service",) + +NAMESPACE = CacheNamespace("test", "api") +CACHE_VERSION = "economy-cache-version" + + +@pytest.fixture +def stable_identifiers( + mock_submission_claim_id, + mock_datetime, +): + """Fix the cache version, the clock and every identifier the service mints.""" + with ( + patch( + "policyengine_api.services.economy_service.get_economy_impact_cache_version", + return_value=CACHE_VERSION, + ), + patch( + "policyengine_api.services.economy_service.resolve_observability_id", + return_value=MOCK_OBSERVABILITY_ID, + ), + patch( + "policyengine_api.services.economy_service.start_observability_id", + side_effect=lambda value: value, + ), + ): + yield + + +@pytest.fixture +def service_environment( + mock_country_package_versions, + mock_policyengine_version, + mock_policy_service, + mock_logger, + stable_identifiers, +): + """Pin package versions, policies, logging and identifiers for one service. + + Every argument is requested for its side effect, since usefixtures marks + don't apply to fixtures. + """ + + +@pytest.fixture +def economy_service( + service_environment, + mock_simulation_entrypoint, +): + return EconomyService( + reform_impacts_service_=ReformImpactsService( + ReformImpactCache(InMemoryCacheBackend(), NAMESPACE) + ), + budget_window_cache_=BudgetWindowCache(InMemoryCacheBackend(), NAMESPACE), + simulation_entrypoint_=mock_simulation_entrypoint, + ) + + +def _expected_setup(**changes: object) -> dict: + values = { + "submission_claim_id": MOCK_SUBMISSION_CLAIM_ID, + "observability_id": None, + "country_id": "us", + "reform_policy_id": MOCK_POLICY_ID, + "baseline_policy_id": MOCK_BASELINE_POLICY_ID, + "region": "state/ca", + "dataset": "default", + "time_period": "2025", + "options": resolved_options(MOCK_OPTIONS), + "api_version": CACHE_VERSION, + "target": "general", + "model_version": MOCK_MODEL_VERSION, + "policyengine_version": MOCK_POLICYENGINE_VERSION, + "data_version": None, + "runtime_app_name": None, + "options_hash": MOCK_LOOKUP_OPTIONS_HASH, + } + values.update(changes) + return values + + +@pytest.mark.parametrize("target", ["general", "cliff"]) +def test_annual_request_resolves_complete_setup_options(economy_service, target): + captured = [] + + def capture(_service, setup_options): + captured.append(setup_options.model_dump()) + return EconomicImpactResult.computing() + + with patch.object(EconomyService, "_get_or_create_economic_impact", capture): + result = economy_service.get_economic_impact( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="ca", + dataset="default", + time_period="2025", + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target=target, + ) + + assert result == EconomicImpactResult.computing() + assert captured == [_expected_setup(target=target)] + + +def test_annual_submission_stores_record_and_sends_payload( + economy_service, mock_simulation_entrypoint, mock_datetime +): + result = economy_service.get_economic_impact( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="state/ca", + dataset="default", + time_period="2025", + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target="cliff", + ) + + assert result == EconomicImpactResult.computing() + payload = mock_simulation_entrypoint.run.call_args.args[0] + config_hash = payload["_telemetry"].pop("config_hash") + assert config_hash.startswith("sha256:") + assert payload == { + "spm": resolved_options({}).get("spm"), + "country": "us", + "scope": "macro", + "reform": {"sample_param": {"2024-01-01.2100-12-31": 15}}, + "baseline": {}, + "time_period": "2025", + "include_cliffs": True, + "region": "state/ca", + "model_version": MOCK_MODEL_VERSION, + "policyengine_version": MOCK_POLICYENGINE_VERSION, + "_metadata": { + "reform_policy_id": MOCK_POLICY_ID, + "baseline_policy_id": MOCK_BASELINE_POLICY_ID, + "submission_claim_id": MOCK_SUBMISSION_CLAIM_ID, + "model_version": MOCK_MODEL_VERSION, + "policyengine_version": MOCK_POLICYENGINE_VERSION, + "data_version": None, + "dataset": "default", + "resolved_app_name": MOCK_RESOLVED_APP_NAME, + }, + "_telemetry": { + "submission_claim_id": MOCK_SUBMISSION_CLAIM_ID, + "requested_at": payload["_telemetry"]["requested_at"], + "simulation_kind": "state", + "geography_code": "ca", + "geography_type": "state", + "capture_mode": "disabled", + }, + } + stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + assert stored == [ + CachedReformImpact( + reform_impact_id=reform_impact_id(MOCK_MODAL_JOB_ID), + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + reform_policy_id=MOCK_POLICY_ID, + country_id="us", + region="state/ca", + dataset="default", + time_period="2025", + options_json=resolved_options(MOCK_OPTIONS), + options_hash=MOCK_OPTIONS_HASH, + api_version=CACHE_VERSION, + reform_impact_json={}, + status="computing", + message=None, + start_time=mock_datetime.now.return_value, + end_time=None, + execution_id=MOCK_MODAL_JOB_ID, + error_code=None, + observability_id=MOCK_OBSERVABILITY_ID, + ) + ] + # The submission claim is released once the execution pointer is stored. + assert ( + economy_service._reform_impacts.get_reform_impact_start_claim( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="state/ca", + dataset="default", + time_period="2025", + options_hash=MOCK_OPTIONS_HASH, + api_version=CACHE_VERSION, + target="cliff", + ) + is None + ) + + +def _budget_window_key(service: EconomyService) -> str: + return service._budget_window_cache.build_key( + country_id="us", + reform_policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="state/ca", + dataset="default", + time_period="budget_window:2026:3", + options_hash=MOCK_LOOKUP_OPTIONS_HASH, + api_version=CACHE_VERSION, + ) + + +def _request_budget_window(service: EconomyService) -> dict: + result = service.get_budget_window_economic_impact( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="ca", + dataset="default", + start_year="2026", + window_size=3, + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + max_active_years=2, + ) + return {**result.to_dict(), "cache_status": result.cache_status} + + +def test_budget_window_with_a_starting_claim_reports_every_year_queued( + economy_service, mock_simulation_entrypoint +): + key = _budget_window_key(economy_service) + assert economy_service._budget_window_cache.claim_batch_start( + key, "another-request", MOCK_OBSERVABILITY_ID + ) + + assert _request_budget_window(economy_service) == { + "status": "computing", + "data": None, + "progress": 0, + "completed_years": [], + "computing_years": [], + "queued_years": ["2026", "2027", "2028"], + "message": "Queued 2026 (0 of 3 complete)...", + "error": None, + "cache_status": "starting-claim-hit", + } + mock_simulation_entrypoint.run_budget_window_batch.assert_not_called() + + +def test_budget_window_submission_stores_state_and_sends_payload( + economy_service, mock_simulation_entrypoint +): + mock_simulation_entrypoint.run_budget_window_batch.return_value = ( + create_mock_budget_window_batch_execution(batch_job_id="batch-7") + ) + + result = _request_budget_window(economy_service) + + assert result == { + "status": "computing", + "data": None, + "progress": 0, + "completed_years": [], + "computing_years": [], + "queued_years": ["2026", "2027", "2028"], + "message": "Queued 2026 (0 of 3 complete)...", + "error": None, + "cache_status": "miss", + } + payload = mock_simulation_entrypoint.run_budget_window_batch.call_args.args[0] + assert payload == { + "spm": resolved_options({}).get("spm"), + "country": "us", + "scope": "macro", + "reform": {"sample_param": {"2024-01-01.2100-12-31": 15}}, + "baseline": {}, + "include_cliffs": False, + "region": "state/ca", + "model_version": MOCK_MODEL_VERSION, + "policyengine_version": MOCK_POLICYENGINE_VERSION, + "start_year": "2026", + "window_size": 3, + "max_parallel": 2, + "target": "general", + } + state = economy_service._budget_window_cache.get_state( + _budget_window_key(economy_service) + ) + assert state is not None + assert state.to_payload() == { + "status": "submitted", + "observability_id": MOCK_OBSERVABILITY_ID, + "batch_job_id": "batch-7", + } + + +def test_budget_window_poll_reports_running_years( + economy_service, mock_simulation_entrypoint +): + mock_simulation_entrypoint.run_budget_window_batch.return_value = ( + create_mock_budget_window_batch_execution(batch_job_id="batch-7") + ) + _request_budget_window(economy_service) + mock_simulation_entrypoint.get_budget_window_batch_by_id.return_value = ( + create_mock_budget_window_batch_execution( + batch_job_id="batch-7", + status="running", + progress=None, + completed_years=["2026"], + running_years=["2027", "2028", "2029"], + queued_years=[], + ) + ) + + result = _request_budget_window(economy_service) + + assert result == { + "status": "computing", + "data": None, + "progress": 33, + "completed_years": ["2026"], + "computing_years": ["2027", "2028", "2029"], + "queued_years": [], + "message": "Scoring 2027, 2028 + 1 more (1 of 3 complete)...", + "error": None, + "cache_status": "batch-id-hit", + } + mock_simulation_entrypoint.get_budget_window_batch_by_id.assert_called_once_with( + "batch-7" + ) + + +@pytest.mark.parametrize( + ("bundle_fields", "expected"), + [ + ( + {"model_version": "1.2.3", "dataset": "default"}, + "[a=1&b={'c': 2}&dataset=default&model_version=1.2.3]", + ), + ( + { + "model_version": None, + "dataset": "hf://data.h5@2", + "runtime_app_name": "app-1", + "data_version": "2", + "policyengine_version": "3.4.0", + }, + "[a=1&b={'c': 2}&dataset=hf://data.h5@2&model_version=None" + "&data_version=2&policyengine_version=3.4.0&runtime_app_name=app-1]", + ), + ], +) +def test_options_hash_lists_options_then_bundle_fields(bundle_fields, expected): + options_hash = EconomyService()._build_options_hash( + options={"b": {"c": 2}, "a": 1}, **bundle_fields + ) + + assert options_hash == expected + + +def test_options_hash_marks_spm_provenance_schema(): + options_hash = EconomyService()._build_options_hash( + options={"spm": {"scenario": "x"}}, + model_version="1", + dataset="default", + runtime_app_name="app", + ) + + assert options_hash == ( + "[spm={'scenario': 'x'}&dataset=default&model_version=1" + "&spm_provenance_schema=canonical-spm-comparison-v2&runtime_app_name=app]" + ) + + +def test_empty_options_hash_drops_the_leading_separator(): + options_hash = EconomyService()._build_options_hash( + options={}, model_version="1", dataset="default" + ) + + assert options_hash == "[dataset=default&model_version=1]" + assert json.dumps(options_hash) diff --git a/tests/unit/services/test_household_calculation_options.py b/tests/unit/services/test_household_calculation_options.py new file mode 100644 index 000000000..f64f30ff0 --- /dev/null +++ b/tests/unit/services/test_household_calculation_options.py @@ -0,0 +1,202 @@ +"""Characterization of how household calculation options reach the country model.""" + +from copy import deepcopy +from typing import ClassVar + +import pytest + +from policyengine_api.runtime_cache.core import CacheNamespace +from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.household_calculations import ( + HouseholdCalculationCache, +) +from policyengine_api.services import household_calculation_service +from policyengine_api.services.household_calculation_service import ( + CalculationResult, + HouseholdCalculationResult, + HouseholdCalculationService, + PreparedHouseholdCalculation, +) + +SPM_CONFIG = { + "forecast_content_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +SPM_RECEIPT = { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "test-artifact", + "forecast_sha256": "a" * 64, + "scenario": "baseline", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, + "years": ["2026"], +} +HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} + + +class RecordingCountry: + """A country model that records each calculation request it receives.""" + + metadata: ClassVar[dict] = { + "variables": { + "age": { + "name": "age", + "entity": "person", + "definitionPeriod": "year", + "defaultValue": 0, + "isInputVariable": True, + }, + "income_tax": { + "name": "income_tax", + "entity": "person", + "definitionPeriod": "year", + "defaultValue": 0, + "isInputVariable": False, + }, + "pay_period_income": { + "name": "pay_period_income", + "entity": "person", + "definitionPeriod": "month", + "defaultValue": 12.5, + "isInputVariable": True, + }, + "daily_hours": { + "name": "daily_hours", + "entity": "person", + "definitionPeriod": "day", + "defaultValue": 8, + "isInputVariable": True, + }, + }, + "entities": {"person": {"plural": "people", "roles": {}}}, + "parameters": {}, + } + + def __init__(self) -> None: + self.requests: list[tuple[dict, dict, dict]] = [] + + def calculate(self, household, policy, **options): + self.requests.append((deepcopy(household), deepcopy(policy), options)) + receipt = SPM_RECEIPT if options.get("spm") is not None else None + return CalculationResult( + household={"calculated": household}, + warnings=("calculated with a fallback",), + spm_provenance=receipt, + ) + + +@pytest.fixture +def country(monkeypatch): + monkeypatch.setattr( + household_calculation_service, + "normalize_spm_selection", + lambda country_id, selection: None if selection is None else dict(selection), + ) + return RecordingCountry() + + +@pytest.fixture +def service(country): + return HouseholdCalculationService( + cache=HouseholdCalculationCache( + InMemoryCacheBackend(), CacheNamespace("test", "api") + ), + country_provider=lambda: {"us": country}, + ) + + +def test_default_calculation_sends_the_household_unchanged(service, country): + result = service.calculate_household("us", deepcopy(HOUSEHOLD), {}) + + assert country.requests == [(HOUSEHOLD, {}, {})] + assert result == HouseholdCalculationResult( + household={"calculated": HOUSEHOLD}, + warnings=("calculated with a fallback",), + ) + + +def test_add_missing_fills_yearly_and_monthly_variables(service, country): + service.calculate_household("us", deepcopy(HOUSEHOLD), {}, add_missing=True) + + assert country.requests == [ + ( + { + "people": { + "you": { + "age": {"2026": 40}, + "income_tax": {"2026": None}, + "pay_period_income": {"2026": 12.5}, + } + } + }, + {}, + {}, + ) + ] + + +@pytest.mark.parametrize("spm_requested", [True, False]) +def test_spm_selection_and_request_flag_reach_the_country( + service, country, spm_requested +): + result = service.calculate_household( + "us", + deepcopy(HOUSEHOLD), + {}, + spm=SPM_CONFIG, + spm_requested=spm_requested, + ) + + assert country.requests == [ + (HOUSEHOLD, {}, {"spm": SPM_CONFIG, "spm_requested": spm_requested}) + ] + assert result.spm_provenance == SPM_RECEIPT + + +def test_prepared_calculation_keeps_every_option(service, country): + prepared = service.prepare_household_calculation( + "us", + deepcopy(HOUSEHOLD), + {}, + add_missing=True, + spm=SPM_CONFIG, + spm_requested=True, + ) + + assert prepared == PreparedHouseholdCalculation( + country=country, + household_json={ + "people": { + "you": { + "age": {"2026": 40}, + "income_tax": {"2026": None}, + "pay_period_income": {"2026": 12.5}, + } + } + }, + policy_json={}, + spm=SPM_CONFIG, + spm_requested=True, + warnings=(), + ) + assert country.requests == [] + + +def test_preparing_does_not_change_the_callers_household(service): + household = deepcopy(HOUSEHOLD) + + service.prepare_household_calculation("us", household, {}, add_missing=True) + + assert household == HOUSEHOLD diff --git a/tests/unit/services/test_worker_spm_result_checks.py b/tests/unit/services/test_worker_spm_result_checks.py new file mode 100644 index 000000000..ad036fd80 --- /dev/null +++ b/tests/unit/services/test_worker_spm_result_checks.py @@ -0,0 +1,222 @@ +"""Characterization of every outcome of the worker economy-result SPM check.""" + +import pytest +from pydantic import ValidationError + +from policyengine_api.spm import SPMValidationError +from policyengine_api.worker_spm import validate_worker_result + +SELECTION = { + "forecast_content_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} +OTHER_SELECTION = {**SELECTION, "scenario": "zero_real"} +CODE = "SPM_CONFIGURATION_UNAVAILABLE" +YEARS_DIFFER = "Budget window SPM result years differ from request" + + +def _receipt(years: tuple[str, ...] = ("2026",)) -> dict: + return { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "test-only", + "forecast_sha256": SELECTION["forecast_content_sha256"], + "scenario": SELECTION["scenario"], + "geography_kind": SELECTION["geography_kind"], + "geography_id": SELECTION["geography_id"], + "county_vintage": SELECTION["county_vintage"], + "as_of": SELECTION["as_of"], + "years": list(years), + "runtime_versions": { + "policyengine": "test", + "policyengine-core": "test", + "policyengine-us": "test", + "spm-calculator": "test", + }, + } + + +def _annual(year: str = "2026", years: tuple[str, ...] = ("2026",)) -> dict: + return { + "year": year, + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": _receipt(years), "execution_count": 1}, + "reform": {"receipt": _receipt(years), "execution_count": 1}, + }, + } + + +def _window(rows: object, *, size: object = None) -> dict: + return { + "kind": "budgetWindow", + "windowSize": len(rows) if size is None else size, + "annualImpacts": rows, + } + + +ACCEPTED = { + "annual-without-year": (_annual(), {}), + "annual-covering-year": (_annual(), {"expected_year": "2026"}), + "annual-covering-int-year": (_annual(), {"expected_year": 2026}), + "window": ( + _window([_annual("2026"), _annual("2027", ("2027",))]), + {"expected_years": ["2026", "2027"]}, + ), + "window-without-years": (_window([_annual("2026")]), {}), +} + + +@pytest.mark.parametrize("case_name", sorted(ACCEPTED)) +def test_matching_results_are_accepted(case_name): + result, expectations = ACCEPTED[case_name] + + assert validate_worker_result(result, SELECTION, **expectations) is None + + +@pytest.mark.parametrize("result", [None, {}, "anything", _window([], size=0)]) +def test_no_requested_selection_accepts_any_result(result): + assert validate_worker_result(result, None, expected_year="2026") is None + + +REJECTED = { + "not-an-object": ( + ["not", "an", "object"], + {}, + "Worker SPM result must be an object", + TypeError, + ), + "window-for-annual-request": ( + _window([_annual()]), + {"expected_year": "2026"}, + "Expected an annual SPM result", + ValueError, + ), + "window-size-zero": ( + _window([], size=0), + {}, + "Budget window size must be a positive integer", + ValueError, + ), + "window-size-bool": ( + _window([_annual()], size=True), + {}, + "Budget window size must be a positive integer", + ValueError, + ), + "window-size-text": ( + _window([_annual()], size="1"), + {}, + "Budget window size must be a positive integer", + ValueError, + ), + "window-rows-not-list": ( + _window({"year": "2026"}, size=1), + {}, + "Budget window annual impacts must be objects", + ValueError, + ), + "window-row-not-object": ( + _window(["2026"], size=1), + {}, + "Budget window annual impacts must be objects", + ValueError, + ), + "window-incomplete": ( + _window([_annual()], size=2), + {}, + "Budget window has incomplete SPM receipts", + ValueError, + ), + "window-year-not-calendar": ( + _window([_annual("26")]), + {}, + "Budget window annual impacts require calendar years", + ValueError, + ), + "window-year-not-ascii": ( + _window([_annual("\uff12\uff10\uff12\uff16")]), + {}, + "Budget window annual impacts require calendar years", + ValueError, + ), + "window-duplicate-years": ( + _window([_annual("2026"), _annual("2026")]), + {}, + YEARS_DIFFER, + ValueError, + ), + "window-other-years": ( + _window([_annual("2026")]), + {"expected_years": ["2027"]}, + YEARS_DIFFER, + ValueError, + ), + "window-int-expected-years": ( + _window([_annual("2026")]), + {"expected_years": [2026]}, + None, + None, + ), + "window-row-uncertified": ( + _window([_annual("2026", ("2025",))]), + {}, + "Worker SPM receipt does not cover requested year", + SPMValidationError, + ), + "annual-for-window-request": ( + _annual(), + {"expected_years": ["2026"]}, + "Expected a budget window SPM result", + ValueError, + ), + "annual-legacy-config": ( + {**_annual(), "spm_config": SELECTION}, + {}, + "Worker result must not include legacy spm_config", + ValueError, + ), + "annual-missing-receipts": ( + {"year": "2026"}, + {}, + None, + ValidationError, + ), + "annual-uncovered-year": ( + _annual(years=("2025",)), + {"expected_year": "2026"}, + "Worker SPM receipt does not cover requested year", + ValueError, + ), +} + + +@pytest.mark.parametrize("case_name", sorted(REJECTED)) +def test_mismatched_results_raise_typed_errors(case_name): + result, expectations, message, cause_type = REJECTED[case_name] + + if cause_type is None: + assert validate_worker_result(result, SELECTION, **expectations) is None + return + with pytest.raises(SPMValidationError) as caught: + validate_worker_result(result, SELECTION, **expectations) + + assert caught.value.code == CODE + assert type(caught.value.__cause__) is cause_type + if message is not None: + assert caught.value.message == message + else: + assert caught.value.message + assert "pydantic.dev" not in caught.value.message + + +def test_receipt_for_another_selection_is_rejected(): + with pytest.raises(SPMValidationError) as caught: + validate_worker_result(_annual(), OTHER_SELECTION, expected_year="2026") + + assert caught.value.code == CODE + assert caught.value.message == "Worker result SPM settings differ from the request" + assert type(caught.value.__cause__) is ValueError diff --git a/tests/unit/test_spm_selection_resolution.py b/tests/unit/test_spm_selection_resolution.py new file mode 100644 index 000000000..1f4396453 --- /dev/null +++ b/tests/unit/test_spm_selection_resolution.py @@ -0,0 +1,259 @@ +"""Characterization of each outcome of resolving an SPM selection against a bundle.""" + +from copy import deepcopy +from datetime import date +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from policyengine_api import spm + +ARTIFACT_HASH = "a" * 64 +OTHER_HASH = "b" * 64 +CERTIFIED_SETTINGS = { + "forecast_content_sha256": ARTIFACT_HASH, + "scenario": "baseline", +} +RESOLVED_DEFAULTS = { + "forecast_content_sha256": ARTIFACT_HASH, + "scenario": "baseline", + "geography_kind": "county", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, +} + + +def _forecast() -> SimpleNamespace: + def entry(_year, *, scenario, as_of): + if scenario not in {"baseline", "alternative"}: + msg = "Unknown forecast scenario" + raise ValueError(msg) + if as_of is not None and date.fromisoformat(as_of) < date(2026, 1, 1): + msg = "Forecast information date follows requested as_of" + raise ValueError(msg) + return {} + + def geography_factor(_year, _tenure, *, geoid, **_kwargs): + if geoid != "31080": + msg = "Forecast geography unavailable" + raise ValueError(msg) + return {} + + return SimpleNamespace( + years=(2022, 2035), entry=entry, geography_factor=geography_factor + ) + + +@pytest.fixture +def install_bundle(monkeypatch): + """Install a bundle manifest and choose whether the US model supports SPM.""" + + def install(bundle: object, *, model_supports_spm: bool = True) -> None: + monkeypatch.setattr(spm, "_current_bundle", lambda: deepcopy(bundle)) + monkeypatch.setattr( + spm, "simulation_supports_spm", lambda _: model_supports_spm + ) + monkeypatch.setattr( + spm, + "_installed_country_implements_spm", + lambda _country_id: model_supports_spm, + ) + monkeypatch.setattr(spm, "_selected_forecast", lambda _expected: _forecast()) + + return install + + +CERTIFIED = {"measurements": {"spm": CERTIFIED_SETTINGS}} +UNCONFIGURED = {"measurements": {}} + +FAILURES = { + "other-country-with-settings": ( + "uk", + {}, + CERTIFIED, + True, + False, + ("SPM_SETTINGS_UNSUPPORTED", "SPM settings are only available for the US"), + None, + ), + "invalid-settings": ( + "us", + {"geography_kind": "state"}, + CERTIFIED, + True, + False, + ("SPM_SETTINGS_INVALID", None), + ValidationError, + ), + "settings-not-an-object": ( + "us", + [], + CERTIFIED, + True, + False, + ("SPM_SETTINGS_INVALID", None), + ValidationError, + ), + "manifest-not-an-object": ( + "us", + None, + ["not", "a", "manifest"], + True, + False, + ("SPM_CONFIGURATION_UNAVAILABLE", "The installed bundle manifest is invalid"), + None, + ), + "capable-model-without-configuration": ( + "us", + None, + UNCONFIGURED, + True, + False, + ( + "SPM_CONFIGURATION_UNAVAILABLE", + "The installed bundle has no certified SPM measurement configuration", + ), + None, + ), + "legacy-model-with-settings": ( + "us", + {"geography_kind": "national"}, + {"measurements": None}, + False, + False, + ( + "SPM_SETTINGS_UNSUPPORTED", + "This US model bundle does not support canonical SPM settings", + ), + None, + ), + "configuration-without-pinned-hash": ( + "us", + None, + {"measurements": {"spm": {"scenario": "baseline"}}}, + True, + False, + ( + "SPM_CONFIGURATION_UNAVAILABLE", + "The bundle must pin the SPM artifact hash and scenario", + ), + ValueError, + ), + "requested-other-artifact": ( + "us", + {"forecast_content_sha256": OTHER_HASH}, + CERTIFIED, + True, + False, + ( + "SPM_SETTINGS_INVALID", + "SPM selection does not match this bundle's artifact hash", + ), + None, + ), + "stored-other-artifact": ( + "us", + {"forecast_content_sha256": OTHER_HASH}, + CERTIFIED, + True, + True, + ( + "SPM_CONFIGURATION_UNAVAILABLE", + "This deployment does not have the SPM artifact this household's " + "saved selection was measured against", + ), + None, + ), + "unknown-scenario": ( + "us", + {"scenario": "nowhere"}, + CERTIFIED, + True, + False, + ("SPM_SETTINGS_INVALID", "Unknown forecast scenario"), + ValueError, + ), + "unsupported-county-vintage": ( + "us", + {"county_vintage": "2010"}, + CERTIFIED, + True, + True, + ("SPM_SETTINGS_INVALID", "Unsupported county vintage: use 2020"), + ValueError, + ), + "unavailable-metro": ( + "us", + {"geography_kind": "metro", "geography_id": "99999"}, + CERTIFIED, + True, + False, + ( + "SPM_GEOGRAPHY_UNAVAILABLE", + "SPM area is unavailable in the selected artifact: 99999", + ), + None, + ), +} + + +@pytest.mark.parametrize("case_name", sorted(FAILURES)) +def test_each_rejected_selection_has_its_code_message_and_cause( + case_name, install_bundle +): + country_id, selection, bundle, supports_spm, stored, expected, cause_type = ( + FAILURES[case_name] + ) + install_bundle(bundle, model_supports_spm=supports_spm) + expected_code, expected_message = expected + + with pytest.raises(spm.SPMValidationError) as caught: + spm.normalize_spm_selection(country_id, selection, stored=stored) + + assert caught.value.code == expected_code + if expected_message is None: + assert caught.value.message + else: + assert caught.value.message == expected_message + if cause_type is None: + assert caught.value.__cause__ is None + else: + assert type(caught.value.__cause__) is cause_type + + +RESOLVED = { + "other-country-without-settings": ("uk", None, CERTIFIED, True, None), + "legacy-model-without-settings": ("us", None, UNCONFIGURED, False, None), + "defaults": ("us", None, CERTIFIED, True, RESOLVED_DEFAULTS), + "explicit-matching-hash": ( + "us", + {"forecast_content_sha256": ARTIFACT_HASH, "scenario": "alternative"}, + CERTIFIED, + True, + {**RESOLVED_DEFAULTS, "scenario": "alternative"}, + ), + "available-metro": ( + "us", + {"geography_kind": "metro", "geography_id": "31080"}, + CERTIFIED, + True, + {**RESOLVED_DEFAULTS, "geography_kind": "metro", "geography_id": "31080"}, + ), + "national-with-information-date": ( + "us", + {"geography_kind": "national", "as_of": "2026-03-01"}, + CERTIFIED, + True, + {**RESOLVED_DEFAULTS, "geography_kind": "national", "as_of": "2026-03-01"}, + ), +} + + +@pytest.mark.parametrize("case_name", sorted(RESOLVED)) +def test_each_accepted_selection_resolves_to_its_identity(case_name, install_bundle): + country_id, selection, bundle, supports_spm, expected = RESOLVED[case_name] + install_bundle(bundle, model_supports_spm=supports_spm) + + assert spm.normalize_spm_selection(country_id, selection) == expected diff --git a/tests/unit/test_worker_spm_release_command.py b/tests/unit/test_worker_spm_release_command.py new file mode 100644 index 000000000..fe7f6f058 --- /dev/null +++ b/tests/unit/test_worker_spm_release_command.py @@ -0,0 +1,58 @@ +"""Characterization of the worker release check's command-line entry point.""" + +import io +import json +import sys + +import pytest + +from policyengine_api import worker_spm_release + +USAGE = "Usage: python -m policyengine_api.worker_spm_release BUNDLE_VERSION" + + +@pytest.mark.parametrize("arguments", [[], ["1.0.0", "extra"]]) +def test_wrong_argument_count_exits_with_usage(monkeypatch, arguments): + validate = [] + monkeypatch.setattr(sys, "argv", ["worker_spm_release", *arguments]) + monkeypatch.setattr( + worker_spm_release, + "validate_installed_worker", + lambda *call: validate.append(call), + ) + + with pytest.raises(SystemExit) as caught: + worker_spm_release.main() + + assert caught.value.code == USAGE + assert validate == [] + + +@pytest.mark.parametrize( + ("selection", "expected_output"), + [ + (None, "The installed legacy bundle and registered worker agree\n"), + ( + {"scenario": "baseline"}, + "The installed bundle and registered worker capability agree\n", + ), + ], +) +def test_one_bundle_version_validates_the_registry_from_stdin( + monkeypatch, capsys, selection, expected_output +): + calls = [] + registry = {"policyengine": {"1.0.0": "worker-app"}} + monkeypatch.setattr(sys, "argv", ["worker_spm_release", "1.0.0"]) + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(registry))) + + def validate(document, bundle_version): + calls.append((document, bundle_version)) + return selection + + monkeypatch.setattr(worker_spm_release, "validate_installed_worker", validate) + + worker_spm_release.main() + + assert calls == [(registry, "1.0.0")] + assert capsys.readouterr().out == expected_output From 7bcdefb85ee785418799fc71b8d4a39ba9882e50 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:21:29 +0400 Subject: [PATCH 02/10] Share one UK area breakdown between constituencies and local authorities Split the constituency and local-authority breakdowns into a region filter, a dataset load, an income-change bucket lookup and one shared per-area loop, and name the poverty age bands and income-change thresholds. Co-Authored-By: Claude Opus 5.5 --- .../services/economy_comparison.py | 453 +++++++++--------- 1 file changed, 223 insertions(+), 230 deletions(-) diff --git a/policyengine_api/services/economy_comparison.py b/policyengine_api/services/economy_comparison.py index 2b917cd72..e51a7c3bc 100644 --- a/policyengine_api/services/economy_comparison.py +++ b/policyengine_api/services/economy_comparison.py @@ -1,6 +1,8 @@ """Reusable economy comparison calculations and response models.""" import logging +from collections.abc import Callable +from typing import NamedTuple import h5py import numpy as np @@ -11,6 +13,43 @@ logger = logging.getLogger(__name__) +# Poverty is broken down into children (under 18), working-age adults and +# seniors (65 and over). +ADULT_MIN_AGE = 18 +SENIOR_MIN_AGE = 65 + +# A relative income change above 5% (or below -5%) is large, and one within +# 0.1% of zero counts as no change. +LARGE_INCOME_CHANGE = 0.05 +NO_INCOME_CHANGE_TOLERANCE = 1e-3 + +# Each bucket's label and the relative change an area must exceed to land in +# it, checked in order; an area that exceeds none loses more than 5%. +INCOME_CHANGE_BUCKET_THRESHOLDS = ( + ("Gain more than 5%", LARGE_INCOME_CHANGE), + ("Gain less than 5%", NO_INCOME_CHANGE_TOLERANCE), + ("No change", -NO_INCOME_CHANGE_TOLERANCE), + ("Lose less than 5%", -LARGE_INCOME_CHANGE), +) +LARGEST_LOSS_BUCKET = "Lose more than 5%" +INCOME_CHANGE_BUCKETS = ( + *(label for label, _threshold in INCOME_CHANGE_BUCKET_THRESHOLDS), + LARGEST_LOSS_BUCKET, +) + +# Constituency and local-authority codes start with a letter for their nation. +UK_NATION_CODE_PREFIXES = { + "england": "E", + "scotland": "S", + "wales": "W", + "northern_ireland": "N", +} +UK_OUTCOME_REGIONS = ("uk", *UK_NATION_CODE_PREFIXES) + +# These UK breakdown artifacts are postprocessor inputs, not the certified +# primary simulation dataset resolved through the PolicyEngine bundle. +UK_AREA_NAMES_REPO = "policyengine/policyengine-uk-data-public" + def budgetary_impact(baseline: dict, reform: dict) -> dict: """Return the reform's change in tax revenue, benefit spending and net budget. @@ -265,18 +304,22 @@ def poverty_impact(baseline: dict, reform: dict) -> dict: ) age = MicroSeries(baseline["age"]) + is_child = age < ADULT_MIN_AGE + is_adult = (age >= ADULT_MIN_AGE) & (age < SENIOR_MIN_AGE) + is_senior = age >= SENIOR_MIN_AGE + poverty = { "child": { - "baseline": float(baseline_poverty[age < 18].mean()), - "reform": float(reform_poverty[age < 18].mean()), + "baseline": float(baseline_poverty[is_child].mean()), + "reform": float(reform_poverty[is_child].mean()), }, "adult": { - "baseline": float(baseline_poverty[(age >= 18) & (age < 65)].mean()), - "reform": float(reform_poverty[(age >= 18) & (age < 65)].mean()), + "baseline": float(baseline_poverty[is_adult].mean()), + "reform": float(reform_poverty[is_adult].mean()), }, "senior": { - "baseline": float(baseline_poverty[age >= 65].mean()), - "reform": float(reform_poverty[age >= 65].mean()), + "baseline": float(baseline_poverty[is_senior].mean()), + "reform": float(reform_poverty[is_senior].mean()), }, "all": { "baseline": float(baseline_poverty.mean()), @@ -286,16 +329,16 @@ def poverty_impact(baseline: dict, reform: dict) -> dict: deep_poverty = { "child": { - "baseline": float(baseline_deep_poverty[age < 18].mean()), - "reform": float(reform_deep_poverty[age < 18].mean()), + "baseline": float(baseline_deep_poverty[is_child].mean()), + "reform": float(reform_deep_poverty[is_child].mean()), }, "adult": { - "baseline": float(baseline_deep_poverty[(age >= 18) & (age < 65)].mean()), - "reform": float(reform_deep_poverty[(age >= 18) & (age < 65)].mean()), + "baseline": float(baseline_deep_poverty[is_adult].mean()), + "reform": float(reform_deep_poverty[is_adult].mean()), }, "senior": { - "baseline": float(baseline_deep_poverty[age >= 65].mean()), - "reform": float(reform_deep_poverty[age >= 65].mean()), + "baseline": float(baseline_deep_poverty[is_senior].mean()), + "reform": float(reform_deep_poverty[is_senior].mean()), }, "all": { "baseline": float(baseline_deep_poverty.mean()), @@ -579,140 +622,177 @@ class UKLocalAuthorityBreakdown(BaseModel): outcomes_by_region: dict[str, dict[str, int]] -def uk_constituency_breakdown( - baseline: dict, reform: dict, country_id: str | None, region: str | None = None -) -> UKConstituencyBreakdown | None: - """Return each UK constituency's average and relative household income change. +class _UKAreaData(NamedTuple): + """One UK area family's household weights and its area table. - Return None outside the UK or for a local-authority region. A constituency or - country region limits the output to that area. Constituency weights and - names come from PolicyEngine's Hugging Face datasets. + ``weights[i, j]`` is the weight of household record ``j`` in the area on row + ``i`` of ``names``, whose columns are code, name and map position (x, y). """ - if country_id != "uk": - return None - # If simulating a local authority, constituency breakdown is not applicable - if region is not None and region.startswith("local_authority/"): - return None + weights: np.ndarray + names: pd.DataFrame + + +class _UKAreaFilter(NamedTuple): + """The single area, or the nation's code prefix, a region limits output to.""" + + selected_area: str | None + nation_code_prefix: str | None + + def includes(self, name: str, code: str) -> bool: + """Return whether the area with this name and code passes the filter.""" + if self.selected_area is not None and self.selected_area not in ( + name, + code, + ): + return False + # Match the leading nation letter with startswith, so a letter anywhere + # else in the code cannot place the area in the wrong nation. + return self.nation_code_prefix is None or code.startswith( + self.nation_code_prefix + ) - # Determine if we're filtering to a specific constituency - selected_constituency = None - if region is not None and region.startswith("constituency/"): - selected_constituency = region.split("/", 1)[1] - # Determine if we're filtering to a specific country - selected_country = None +def _uk_area_filter( + region: str | None, + *, + area_region_prefix: str, + normalize_nation: Callable[[str], str], +) -> _UKAreaFilter: + """Read the area or nation a region selects. + + ``normalize_nation`` gives the case in which the region's nation name is + compared with the known nations. + """ + selected_area = None + if region is not None and region.startswith(area_region_prefix): + selected_area = region.split("/", 1)[1] + + nation_code_prefix = None if region is not None and region.startswith("country/"): - selected_country = region.split("/", 1)[1].upper() + selected_nation = normalize_nation(region.split("/", 1)[1]) + nation_code_prefix = { + normalize_nation(nation): prefix + for nation, prefix in UK_NATION_CODE_PREFIXES.items() + }.get(selected_nation) + return _UKAreaFilter(selected_area, nation_code_prefix) + + +def _load_uk_area_data( + *, weights_repo: str, weights_filename: str, names_filename: str +) -> _UKAreaData: + """Download one area family's 2025 household weights and its area table.""" + weights_path = download_huggingface_dataset( + repo=weights_repo, + repo_filename=weights_filename, + ) + with h5py.File(weights_path, "r") as weights_file: + weights = weights_file["2025"][...] + + names_path = download_huggingface_dataset( + repo=UK_AREA_NAMES_REPO, + repo_filename=names_filename, + ) + return _UKAreaData(weights=weights, names=pd.read_csv(names_path)) + + +def _income_change_bucket(relative_change: float) -> str: + """Return the income-change bucket a relative household income change is in.""" + for label, threshold in INCOME_CHANGE_BUCKET_THRESHOLDS: + if relative_change > threshold: + return label + return LARGEST_LOSS_BUCKET + + +def _outcome_regions(code: str) -> list[str]: + """Return the UK and, when its code names one, the nation an area counts in.""" + regions = ["uk"] + for nation, prefix in UK_NATION_CODE_PREFIXES.items(): + if code.startswith(prefix): + regions.append(nation) + break + return regions - output = { - "by_constituency": {}, - "outcomes_by_region": {}, + +def _uk_area_breakdown( + baseline_net_income: object, + reform_net_income: object, + area_data: _UKAreaData, + area_filter: _UKAreaFilter, +) -> tuple[dict[str, dict], dict[str, dict[str, int]]]: + """Return each selected area's income change and the bucket counts by region. + + The first mapping is keyed by area name in table order; the second counts + the selected areas in the UK and each nation by income-change bucket. + """ + by_area: dict[str, dict] = {} + outcomes_by_region = { + region_name: dict.fromkeys(INCOME_CHANGE_BUCKETS, 0) + for region_name in UK_OUTCOME_REGIONS } - for region_name in [ - "uk", - "england", - "scotland", - "wales", - "northern_ireland", - ]: - output["outcomes_by_region"][region_name] = { - "Gain more than 5%": 0, - "Gain less than 5%": 0, - "No change": 0, - "Lose less than 5%": 0, - "Lose more than 5%": 0, - } - baseline_hnet = baseline["household_net_income"] - reform_hnet = reform["household_net_income"] - - # These UK breakdown artifacts are postprocessor inputs, not the certified - # primary simulation dataset resolved through the PolicyEngine bundle. - constituency_weights_path = download_huggingface_dataset( - repo="policyengine/policyengine-uk-data", - repo_filename="parliamentary_constituency_weights.h5", - ) - with h5py.File(constituency_weights_path, "r") as f: - # {2025: array(650, 100180) where cell i, j is the weight of household - # record i in constituency j} - weights = f["2025"][...] - - constituency_names_path = download_huggingface_dataset( - repo="policyengine/policyengine-uk-data-public", - repo_filename="constituencies_2024.csv", - ) - # Columns: code (constituency code), name (constituency name), x, y - # (geographic position). - constituency_names = pd.read_csv(constituency_names_path) - - for i in range(len(constituency_names)): - name: str = constituency_names.iloc[i]["name"] - code: str = constituency_names.iloc[i]["code"] - - # Filter to specific constituency if requested - if ( - selected_constituency is not None - and name != selected_constituency - and code != selected_constituency - ): + for index in range(len(area_data.names)): + name: str = area_data.names.iloc[index]["name"] + code: str = area_data.names.iloc[index]["code"] + if not area_filter.includes(name, code): continue - # Filter to specific country if requested. Constituency codes - # are prefixed with a single letter that identifies the - # country (E=England, S=Scotland, W=Wales, N=Northern - # Ireland); use startswith so we don't accidentally match a - # letter anywhere else in the code. - if selected_country is not None: - if selected_country == "ENGLAND" and not code.startswith("E"): - continue - if selected_country == "SCOTLAND" and not code.startswith("S"): - continue - if selected_country == "WALES" and not code.startswith("W"): - continue - if selected_country == "NORTHERN_IRELAND" and not code.startswith("N"): - continue - - weight: np.ndarray = weights[i] - baseline_income = MicroSeries(baseline_hnet, weights=weight) - reform_income = MicroSeries(reform_hnet, weights=weight) + weight: np.ndarray = area_data.weights[index] + baseline_income = MicroSeries(baseline_net_income, weights=weight) + reform_income = MicroSeries(reform_net_income, weights=weight) average_household_income_change: float = ( reform_income.sum() - baseline_income.sum() ) / baseline_income.count() percent_household_income_change: float = ( reform_income.sum() / baseline_income.sum() - 1 ) - output["by_constituency"][name] = { + by_area[name] = { "average_household_income_change": average_household_income_change, "relative_household_income_change": percent_household_income_change, - "x": int(constituency_names.iloc[i]["x"]), # Geographic positions - "y": int(constituency_names.iloc[i]["y"]), + "x": int(area_data.names.iloc[index]["x"]), # Geographic positions + "y": int(area_data.names.iloc[index]["y"]), } - regions = ["uk"] - if code.startswith("E"): - regions.append("england") - elif code.startswith("S"): - regions.append("scotland") - elif code.startswith("W"): - regions.append("wales") - elif code.startswith("N"): - regions.append("northern_ireland") - - if percent_household_income_change > 0.05: - bucket = "Gain more than 5%" - elif percent_household_income_change > 1e-3: - bucket = "Gain less than 5%" - elif percent_household_income_change > -1e-3: - bucket = "No change" - elif percent_household_income_change > -0.05: - bucket = "Lose less than 5%" - else: - bucket = "Lose more than 5%" - - for region_ in regions: - output["outcomes_by_region"][region_][bucket] += 1 - - return UKConstituencyBreakdown(**output) + bucket = _income_change_bucket(percent_household_income_change) + for region_name in _outcome_regions(code): + outcomes_by_region[region_name][bucket] += 1 + return by_area, outcomes_by_region + + +def uk_constituency_breakdown( + baseline: dict, reform: dict, country_id: str | None, region: str | None = None +) -> UKConstituencyBreakdown | None: + """Return each UK constituency's average and relative household income change. + + Return None outside the UK or for a local-authority region. A constituency or + country region limits the output to that area. Constituency weights and + names come from PolicyEngine's Hugging Face datasets. + """ + if country_id != "uk": + return None + + # If simulating a local authority, constituency breakdown is not applicable + if region is not None and region.startswith("local_authority/"): + return None + + area_filter = _uk_area_filter( + region, + area_region_prefix="constituency/", + normalize_nation=str.upper, + ) + baseline_net_income = baseline["household_net_income"] + reform_net_income = reform["household_net_income"] + constituencies = _load_uk_area_data( + weights_repo="policyengine/policyengine-uk-data", + weights_filename="parliamentary_constituency_weights.h5", + names_filename="constituencies_2024.csv", + ) + by_constituency, outcomes_by_region = _uk_area_breakdown( + baseline_net_income, reform_net_income, constituencies, area_filter + ) + return UKConstituencyBreakdown( + by_constituency=by_constituency, + outcomes_by_region=outcomes_by_region, + ) def uk_local_authority_breakdown( @@ -731,112 +811,25 @@ def uk_local_authority_breakdown( if region is not None and region.startswith("constituency/"): return None - # Determine if we're filtering to a specific local authority - selected_la = None - if region is not None and region.startswith("local_authority/"): - selected_la = region.split("/", 1)[1] - - # Determine if we're filtering to a specific country - selected_country = None - if region is not None and region.startswith("country/"): - selected_country = region.split("/", 1)[1].lower() - - output = { - "by_local_authority": {}, - "outcomes_by_region": {}, - } - for region_name in [ - "uk", - "england", - "scotland", - "wales", - "northern_ireland", - ]: - output["outcomes_by_region"][region_name] = { - "Gain more than 5%": 0, - "Gain less than 5%": 0, - "No change": 0, - "Lose less than 5%": 0, - "Lose more than 5%": 0, - } - baseline_hnet = baseline["household_net_income"] - reform_hnet = reform["household_net_income"] - - # These UK breakdown artifacts are postprocessor inputs, not the certified - # primary simulation dataset resolved through the PolicyEngine bundle. - local_authority_weights_path = download_huggingface_dataset( - repo="policyengine/policyengine-uk-data-private", - repo_filename="local_authority_weights.h5", + area_filter = _uk_area_filter( + region, + area_region_prefix="local_authority/", + normalize_nation=str.lower, ) - with h5py.File(local_authority_weights_path, "r") as f: - weights = f["2025"][...] - - local_authority_names_path = download_huggingface_dataset( - repo="policyengine/policyengine-uk-data-public", - repo_filename="local_authorities_2021.csv", + baseline_net_income = baseline["household_net_income"] + reform_net_income = reform["household_net_income"] + local_authorities = _load_uk_area_data( + weights_repo="policyengine/policyengine-uk-data-private", + weights_filename="local_authority_weights.h5", + names_filename="local_authorities_2021.csv", + ) + by_local_authority, outcomes_by_region = _uk_area_breakdown( + baseline_net_income, reform_net_income, local_authorities, area_filter + ) + return UKLocalAuthorityBreakdown( + by_local_authority=by_local_authority, + outcomes_by_region=outcomes_by_region, ) - local_authority_names = pd.read_csv(local_authority_names_path) - - for i in range(len(local_authority_names)): - name: str = local_authority_names.iloc[i]["name"] - code: str = local_authority_names.iloc[i]["code"] - - # Filter to specific local authority if requested - if selected_la is not None and name != selected_la and code != selected_la: - continue - - # Filter to specific country if requested - if selected_country is not None: - if selected_country == "england" and not code.startswith("E"): - continue - if selected_country == "scotland" and not code.startswith("S"): - continue - if selected_country == "wales" and not code.startswith("W"): - continue - if selected_country == "northern_ireland" and not code.startswith("N"): - continue - - weight: np.ndarray = weights[i] - baseline_income = MicroSeries(baseline_hnet, weights=weight) - reform_income = MicroSeries(reform_hnet, weights=weight) - average_household_income_change: float = ( - reform_income.sum() - baseline_income.sum() - ) / baseline_income.count() - percent_household_income_change: float = ( - reform_income.sum() / baseline_income.sum() - 1 - ) - output["by_local_authority"][name] = { - "average_household_income_change": average_household_income_change, - "relative_household_income_change": percent_household_income_change, - "x": int(local_authority_names.iloc[i]["x"]), - "y": int(local_authority_names.iloc[i]["y"]), - } - - regions = ["uk"] - if code.startswith("E"): - regions.append("england") - elif code.startswith("S"): - regions.append("scotland") - elif code.startswith("W"): - regions.append("wales") - elif code.startswith("N"): - regions.append("northern_ireland") - - if percent_household_income_change > 0.05: - bucket = "Gain more than 5%" - elif percent_household_income_change > 1e-3: - bucket = "Gain less than 5%" - elif percent_household_income_change > -1e-3: - bucket = "No change" - elif percent_household_income_change > -0.05: - bucket = "Lose less than 5%" - else: - bucket = "Lose more than 5%" - - for region_ in regions: - output["outcomes_by_region"][region_][bucket] += 1 - - return UKLocalAuthorityBreakdown(**output) def compare_economic_outputs( From 679536ea923ead5cd639af6bd14a50f23863c5e5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:26:51 +0400 Subject: [PATCH 03/10] Split SPM selection resolution and worker result checks into steps Resolve an SPM selection in parse, bundle-configuration and certified-merge steps, check budget-window and annual worker results in separate helpers, and name the calendar-year length and the release command's argument count. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/spm.py | 120 +++++++++++++++++-------- policyengine_api/worker_spm.py | 73 +++++++++------ policyengine_api/worker_spm_release.py | 4 +- 3 files changed, 134 insertions(+), 63 deletions(-) diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 2f87ba09c..65ef07e5f 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -11,7 +11,7 @@ from collections.abc import Mapping, Sequence from datetime import date from functools import lru_cache -from typing import TYPE_CHECKING, Any, Literal, Self +from typing import TYPE_CHECKING, Any, Literal, NoReturn, Self from pydantic import ( BaseModel, @@ -30,6 +30,9 @@ from pydantic import SerializerFunctionWrapHandler from spm_calculator.rolling_forecast import SPMForecast +# Receipts and worker results name years as four-digit calendar years. +CALENDAR_YEAR_LENGTH = 4 + class SPMSelection(BaseModel): """Select a forecast scenario and an explicit household SPM geography.""" @@ -168,7 +171,10 @@ class SPMProvenance(BaseModel): def validate_years(cls, years: tuple[str, ...]) -> tuple[str, ...]: """Require sorted, unique four-digit year strings.""" if any( - len(year) != 4 or not year.isascii() or not year.isdigit() for year in years + len(year) != CALENDAR_YEAR_LENGTH + or not year.isascii() + or not year.isdigit() + for year in years ): msg = "years must contain four-digit calendar years" raise ValueError(msg) @@ -564,37 +570,64 @@ def normalize_spm_selection( ) return None + chosen = _requested_spm_selection(selection) + configured = _certified_spm_configuration( + country_id, selection_sent=selection is not None + ) + if configured is None: + return None + return _resolve_certified_selection(chosen, configured, stored=stored) + + +def _requested_spm_selection(selection: object) -> SPMSelection: + """Parse the caller's settings; raise SPM_SETTINGS_INVALID if they are invalid.""" try: - chosen = SPMSelection.model_validate({} if selection is None else selection) + return SPMSelection.model_validate({} if selection is None else selection) except ValidationError as error: error_code = "SPM_SETTINGS_INVALID" raise SPMValidationError( error_code, readable_validation_error(error) ) from error + +def _certified_spm_configuration( + country_id: str, *, selection_sent: bool +) -> dict | None: + """Return the bundle's certified SPM settings, or None for a legacy bundle. + + Raise SPM_CONFIGURATION_UNAVAILABLE for an unreadable manifest or for a model + that can run canonical SPM but has no certified configuration, and + SPM_SETTINGS_UNSUPPORTED when settings were sent to a legacy bundle. + """ bundle = _current_bundle() if not isinstance(bundle, dict): error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError(error_code, "The installed bundle manifest is invalid") measurements = bundle.get("measurements") configured = measurements.get("spm") if isinstance(measurements, dict) else None - if not isinstance(configured, dict): - if _installed_country_implements_spm(country_id): - # A model that can run canonical SPM must never run it uncertified, - # whichever settings the caller did or did not send. - error_code = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError( - error_code, - "The installed bundle has no certified SPM measurement configuration", - ) - if selection is None: - return None - error_code = "SPM_SETTINGS_UNSUPPORTED" + if isinstance(configured, dict): + return configured + if _installed_country_implements_spm(country_id): + # A model that can run canonical SPM must never run it uncertified, + # whichever settings the caller did or did not send. + error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( error_code, - "This US model bundle does not support canonical SPM settings", + "The installed bundle has no certified SPM measurement configuration", ) + if not selection_sent: + return None + error_code = "SPM_SETTINGS_UNSUPPORTED" + raise SPMValidationError( + error_code, + "This US model bundle does not support canonical SPM settings", + ) + +def _resolve_certified_selection( + chosen: SPMSelection, configured: dict, *, stored: bool +) -> dict: + """Merge the caller's settings over the certified defaults and validate them.""" try: defaults, forecast = _certified_bundle_spm(configured) except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: @@ -606,22 +639,44 @@ def normalize_spm_selection( raise SPMValidationError(error_code, error_message(error)) from error if chosen.forecast_content_sha256 not in (None, defaults.forecast_content_sha256): - # A saved selection is identity, not a request. The caller sent nothing - # wrong; this deployment simply does not have the artifact the household - # was measured against, which is the same class of failure as an - # uncertified bundle rather than a correctable caller mistake. - if stored: - error_code = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError( - error_code, - "This deployment does not have the SPM artifact this household's " - "saved selection was measured against", - ) + _raise_unavailable_artifact(stored=stored) + values = _merged_selection_values(chosen, defaults) + try: + resolved = _validated_spm_selection(values, forecast) + except ValueError as error: error_code = "SPM_SETTINGS_INVALID" + raise SPMValidationError(error_code, error_message(error)) from error + if resolved.geography_kind == "metro": + _validate_metro_selection(forecast, resolved) + return resolved.model_dump(mode="json") + + +def _raise_unavailable_artifact(*, stored: bool) -> NoReturn: + """Reject a selection naming a forecast artifact this bundle does not have.""" + # A saved selection is identity, not a request. The caller sent nothing + # wrong; this deployment simply does not have the artifact the household + # was measured against, which is the same class of failure as an + # uncertified bundle rather than a correctable caller mistake. + if stored: + error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( error_code, - "SPM selection does not match this bundle's artifact hash", + "This deployment does not have the SPM artifact this household's " + "saved selection was measured against", ) + error_code = "SPM_SETTINGS_INVALID" + raise SPMValidationError( + error_code, + "SPM selection does not match this bundle's artifact hash", + ) + + +def _merged_selection_values(chosen: SPMSelection, defaults: SPMSelection) -> dict: + """Overlay the settings the caller sent on the bundle defaults. + + Changing the geography kind also replaces an inherited geography ID, and the + artifact 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 ( @@ -631,14 +686,7 @@ def normalize_spm_selection( values["geography_id"] = chosen.geography_id values["forecast_content_sha256"] = defaults.forecast_content_sha256 values["scenario"] = chosen.scenario or defaults.scenario - try: - resolved = _validated_spm_selection(values, forecast) - except ValueError as error: - error_code = "SPM_SETTINGS_INVALID" - raise SPMValidationError(error_code, error_message(error)) from error - if resolved.geography_kind == "metro": - _validate_metro_selection(forecast, resolved) - return resolved.model_dump(mode="json") + return values def _validate_metro_selection(forecast: SPMForecast, selection: SPMSelection) -> None: diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index 757def5be..742656be9 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Protocol from policyengine_api.spm import ( + CALENDAR_YEAR_LENGTH, SPMComparisonProvenance, SPMSelection, SPMValidationError, @@ -129,38 +130,58 @@ def _check_worker_result( if expected_year is not None: msg = "Expected an annual SPM result" raise ValueError(msg) - rows = result.get("annualImpacts") - size = result.get("windowSize") - if type(size) is not int or size < 1: - msg = "Budget window size must be a positive integer" - raise ValueError(msg) - if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): - msg = "Budget window annual impacts must be objects" - raise ValueError(msg) - if len(rows) != size: - msg = "Budget window has incomplete SPM receipts" - raise ValueError(msg) - actual = [str(row.get("year")) for row in rows] - if any( - len(year) != 4 or not year.isascii() or not year.isdigit() - for year in actual - ): - msg = "Budget window annual impacts require calendar years" - raise ValueError(msg) - if len(set(actual)) != len(actual): - msg = "Budget window SPM result years differ from request" - raise ValueError(msg) - if expected_years is not None: - wanted = [str(year) for year in expected_years] - if actual != wanted: - msg = "Budget window SPM result years differ from request" - raise ValueError(msg) + rows = _budget_window_rows(result) + _check_budget_window_years(rows, expected_years) for row in rows: validate_worker_result(row, selection, expected_year=row.get("year")) return if expected_years is not None: msg = "Expected a budget window SPM result" raise ValueError(msg) + _check_annual_receipts(result, selection, expected_year=expected_year) + + +def _budget_window_rows(result: dict) -> list[dict]: + """Return a budget window's annual results; raise ValueError if any is missing.""" + rows = result.get("annualImpacts") + size = result.get("windowSize") + if type(size) is not int or size < 1: + msg = "Budget window size must be a positive integer" + raise ValueError(msg) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + msg = "Budget window annual impacts must be objects" + raise ValueError(msg) + if len(rows) != size: + msg = "Budget window has incomplete SPM receipts" + raise ValueError(msg) + return rows + + +def _check_budget_window_years( + rows: list[dict], expected_years: list[str] | None +) -> None: + """Raise ValueError unless each row has a distinct, requested calendar year.""" + actual = [str(row.get("year")) for row in rows] + if any( + len(year) != CALENDAR_YEAR_LENGTH or not year.isascii() or not year.isdigit() + for year in actual + ): + msg = "Budget window annual impacts require calendar years" + raise ValueError(msg) + if len(set(actual)) != len(actual): + msg = "Budget window SPM result years differ from request" + raise ValueError(msg) + if expected_years is not None: + wanted = [str(year) for year in expected_years] + if actual != wanted: + msg = "Budget window SPM result years differ from request" + raise ValueError(msg) + + +def _check_annual_receipts( + result: dict, selection: dict, *, expected_year: str | int | None +) -> None: + """Raise unless both receipts resolve to the selection and cover the year.""" if "spm_config" in result: msg = "Worker result must not include legacy spm_config" raise ValueError(msg) diff --git a/policyengine_api/worker_spm_release.py b/policyengine_api/worker_spm_release.py index bd1f5528c..d229dee11 100644 --- a/policyengine_api/worker_spm_release.py +++ b/policyengine_api/worker_spm_release.py @@ -9,6 +9,8 @@ from policyengine_api import constants from policyengine_api.worker_spm import validate_worker_spm +# The command line is the program name followed by the bundle version. +COMMAND_LINE_LENGTH = 2 REQUIRED_BUNDLE_PACKAGES = ( "policyengine", "policyengine-core", @@ -113,7 +115,7 @@ def validate_installed_worker(registry: dict, expected_bundle: str) -> dict | No def main() -> None: """Read a registry document from stdin and validate one bundle version.""" - if len(sys.argv) != 2: + if len(sys.argv) != COMMAND_LINE_LENGTH: msg = "Usage: python -m policyengine_api.worker_spm_release BUNDLE_VERSION" raise SystemExit(msg) selection = validate_installed_worker(json.load(sys.stdin), sys.argv[1]) From 3677394bd87cc415d106ee452f7fdbf0e26db47a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:26:51 +0400 Subject: [PATCH 04/10] Split the calculated-household cache read into shape and receipt checks Also name the in-memory fake's compare-and-delete argument count. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/runtime_cache/fake.py | 6 +- .../runtime_cache/household_calculations.py | 62 ++++++++++++------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/policyengine_api/runtime_cache/fake.py b/policyengine_api/runtime_cache/fake.py index 0cb1e8a07..962f2ca06 100644 --- a/policyengine_api/runtime_cache/fake.py +++ b/policyengine_api/runtime_cache/fake.py @@ -8,6 +8,10 @@ if TYPE_CHECKING: from collections.abc import Callable +# The claim store's compare-and-delete script takes one key and one argument: +# the token the key must still hold. +COMPARE_AND_DELETE_ARGUMENT_COUNT = 2 + class InMemoryCacheBackend: """Small Redis-like fake with explicit time advancement and atomic pipelines.""" @@ -153,7 +157,7 @@ def eval( The script text is ignored: delete the one key if it still holds the expected token, and raise ValueError for any other key/argument shape. """ - if numkeys != 1 or len(keys_and_args) != 2: + if numkeys != 1 or len(keys_and_args) != COMPARE_AND_DELETE_ARGUMENT_COUNT: msg = "fake supports one-key compare-and-delete only" raise ValueError(msg) key, expected = keys_and_args diff --git a/policyengine_api/runtime_cache/household_calculations.py b/policyengine_api/runtime_cache/household_calculations.py index 1082054ef..9fa7924e2 100644 --- a/policyengine_api/runtime_cache/household_calculations.py +++ b/policyengine_api/runtime_cache/household_calculations.py @@ -1,7 +1,7 @@ """Recoverable calculated-household result caching.""" from dataclasses import asdict, dataclass -from typing import Any +from typing import Any, TypeGuard from pydantic import ValidationError @@ -74,36 +74,19 @@ def get( different SPM configuration than ``identity`` asks for is also a miss. """ payload = self._cache.get(asdict(identity)) - if not isinstance(payload, dict): - return None - household = payload.get("household") - warnings = payload.get("warnings") - if not isinstance(household, dict) or not isinstance(warnings, list): - return None - if not all(isinstance(warning, str) for warning in warnings): - return None - if "spm_config" in payload: + if not _is_well_formed_entry(payload): return None spm_provenance = payload.get("spm_provenance") - if spm_provenance is not None and not isinstance(spm_provenance, dict): - return None if identity.spm is None: if spm_provenance is not None: return None else: - try: - receipt = validate_spm_calculation_provenance(spm_provenance) - except (ValidationError, ValueError): - return None - if ( - resolved_spm_configuration(receipt).model_dump(mode="json") - != identity.spm - ): + spm_provenance = _certified_receipt(spm_provenance, identity.spm) + if spm_provenance is None: return None - spm_provenance = receipt.model_dump(mode="json", by_alias=True) return CachedHouseholdCalculation( - household=household, - warnings=tuple(warnings), + household=payload["household"], + warnings=tuple(payload["warnings"]), spm_provenance=spm_provenance, ) @@ -130,3 +113,36 @@ def set( payload = asdict(value) payload["spm_provenance"] = receipt.model_dump(mode="json", by_alias=True) return self._cache.set(asdict(identity), payload) + + +def _is_well_formed_entry(payload: object) -> TypeGuard[dict[str, Any]]: + """Return whether a cached payload has the current calculated-household shape. + + That is a household object, a list of string warnings, no legacy + ``spm_config`` and an SPM receipt that is an object when present. + """ + if not isinstance(payload, dict): + return False + household = payload.get("household") + warnings = payload.get("warnings") + if not isinstance(household, dict) or not isinstance(warnings, list): + return False + if not all(isinstance(warning, str) for warning in warnings): + return False + if "spm_config" in payload: + return False + spm_provenance = payload.get("spm_provenance") + return spm_provenance is None or isinstance(spm_provenance, dict) + + +def _certified_receipt( + spm_provenance: dict[str, Any] | None, expected_spm: dict[str, Any] +) -> dict[str, Any] | None: + """Return the receipt in canonical form, or None unless it certifies the SPM.""" + try: + receipt = validate_spm_calculation_provenance(spm_provenance) + except (ValidationError, ValueError): + return None + if resolved_spm_configuration(receipt).model_dump(mode="json") != expected_spm: + return None + return receipt.model_dump(mode="json", by_alias=True) From dfa3a752ff3ac269ea41c12b77bc5f8e0a5676b0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:48:47 +0400 Subject: [PATCH 05/10] Pass reform-impact and budget-window cache scopes as objects Group the policy pair, region, dataset and period into ReformImpactScope and add ReformImpactIdentity (scope, cache version and options hash), so the reform-impact cache, its service and the budget-window cache key take one object instead of eight or more fields. Stored impacts take a ReformImpactExecution (execution ID, start time, observability ID), and the error and completion updates drop the scope arguments they ignored. Cache keys and stored records are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../runtime_cache/reform_impacts.py | 163 +++++-------- .../services/budget_window_cache.py | 29 ++- policyengine_api/services/economy_service.py | 107 ++++----- .../services/reform_impacts_service.py | 214 +++++------------- tests/integration/test_runtime_cache_redis.py | 22 +- .../routes/test_economy_failure_replay.py | 17 +- .../unit/routes/test_reform_impact_routes.py | 45 ++-- .../routes/test_spm_year_worker_polling.py | 29 +-- .../unit/runtime_cache/test_reform_impacts.py | 52 ++--- .../test_runtime_cache_contracts.py | 143 ++++-------- .../unit/services/test_budget_window_cache.py | 55 +++-- .../test_direct_orm_local_analysis.py | 33 ++- tests/unit/services/test_economy_service.py | 87 +++---- .../test_economy_service_request_plumbing.py | 27 ++- .../services/test_reform_impacts_service.py | 110 +++------ 15 files changed, 430 insertions(+), 703 deletions(-) diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index decb7ba25..874804a92 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -57,6 +57,31 @@ def from_payload(cls, payload: object) -> "ReformImpactStartClaim | None": ) +@dataclass(frozen=True) +class ReformImpactScope: + """The policy pair, region, dataset and period a reform impact is computed for.""" + + country_id: str + reform_policy_id: int + baseline_policy_id: int + region: str + dataset: str + time_period: str + + +@dataclass(frozen=True) +class ReformImpactIdentity: + """Everything that identifies one cached reform impact calculation. + + ``api_version`` is the cache generation and ``options_hash`` identifies the + calculation options and runtime bundle. + """ + + scope: ReformImpactScope + api_version: str + options_hash: str | None + + @dataclass(frozen=True) class CachedReformImpact: """An economy-impact job and its result, shaped like a legacy reform_impact row. @@ -175,32 +200,21 @@ def __init__(self, client: CacheBackend, namespace: CacheNamespace) -> None: self.namespace = namespace self._start_claims = ExpiringClaimStore(client, family=self.family) - def _start_claim_key( - self, - *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - api_version: str, - options_hash: str, - target: str, - ) -> str: + def _start_claim_key(self, identity: ReformImpactIdentity, target: str) -> str: + scope = identity.scope return self.namespace.key( REFORM_IMPACT_START_CLAIM_FAMILY, REFORM_IMPACT_SCHEMA_VERSION, { - "api_version": api_version, - "baseline_policy_id": baseline_policy_id, - "country_id": country_id, - "dataset": dataset, - "options_hash": options_hash, - "reform_policy_id": reform_policy_id, - "region": region, + "api_version": identity.api_version, + "baseline_policy_id": scope.baseline_policy_id, + "country_id": scope.country_id, + "dataset": scope.dataset, + "options_hash": identity.options_hash, + "reform_policy_id": scope.reform_policy_id, + "region": scope.region, "target": target, - "time_period": time_period, + "time_period": scope.time_period, }, ) @@ -214,15 +228,8 @@ def _encoded_start_claim(claim: ReformImpactStartClaim) -> str: def claim_start( self, + identity: ReformImpactIdentity, *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - api_version: str, - options_hash: str, target: str, claim_token: str, observability_id: str, @@ -238,47 +245,20 @@ def claim_start( msg = "a claim token and valid observability identifier are required" raise ValueError(msg) return self._start_claims.acquire( - self._start_claim_key( - country_id=country_id, - reform_policy_id=reform_policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, - target=target, - ), + self._start_claim_key(identity, target), self._encoded_start_claim(claim), ttl_seconds=REFORM_IMPACT_START_CLAIM_TTL_SECONDS, ) def get_start_claim( self, + identity: ReformImpactIdentity, *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - api_version: str, - options_hash: str, target: str, ) -> ReformImpactStartClaim | None: """Read the current submission owner and its diagnostic identifier.""" started_at = time.perf_counter() - key = self._start_claim_key( - country_id=country_id, - reform_policy_id=reform_policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, - target=target, - ) + key = self._start_claim_key(identity, target) try: encoded = self.client.get(key) except Exception as error: @@ -319,15 +299,8 @@ def get_start_claim( def release_start( self, + identity: ReformImpactIdentity, *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - api_version: str, - options_hash: str, target: str, claim_token: str, observability_id: str, @@ -344,17 +317,7 @@ def release_start( raise ValueError(msg) return self._start_claims.release( - self._start_claim_key( - country_id=country_id, - reform_policy_id=reform_policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, - target=target, - ), + self._start_claim_key(identity, target), self._encoded_start_claim(claim), ) @@ -484,15 +447,8 @@ def recent(self, limit: int) -> list[CachedReformImpact]: def matching( self, + identity: ReformImpactIdentity, *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - api_version: str, - options_hash: str, options_hash_pattern: str | None = None, ) -> list[CachedReformImpact]: """Return the cached impacts for one policy pair and scope, best match first. @@ -501,17 +457,19 @@ def matching( ``options_hash_pattern``, a SQL ``LIKE`` pattern; ties go to the most recently started. Only the scope index's newest 1,000 entries are read. """ + scope = identity.scope + options_hash = identity.options_hash probe = CachedReformImpact( reform_impact_id=0, - baseline_policy_id=baseline_policy_id, - reform_policy_id=reform_policy_id, - country_id=country_id, - region=region, - dataset=dataset, - time_period=time_period, + baseline_policy_id=scope.baseline_policy_id, + reform_policy_id=scope.reform_policy_id, + country_id=scope.country_id, + region=scope.region, + dataset=scope.dataset, + time_period=scope.time_period, options_json=None, options_hash=options_hash, - api_version=api_version, + api_version=identity.api_version, reform_impact_json={}, status="", message=None, @@ -565,13 +523,8 @@ def update( def delete_matching_computing( self, + scope: ReformImpactScope, *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, options_hash: str, ) -> None: """Remove in-flight (``computing``) impacts for this scope and options. @@ -584,12 +537,12 @@ def delete_matching_computing( selected = [ impact for impact in impacts - if impact.country_id == country_id - and impact.reform_policy_id == reform_policy_id - and impact.baseline_policy_id == baseline_policy_id - and impact.region == region - and impact.dataset == dataset - and impact.time_period == time_period + if impact.country_id == scope.country_id + and impact.reform_policy_id == scope.reform_policy_id + and impact.baseline_policy_id == scope.baseline_policy_id + and impact.region == scope.region + and impact.dataset == scope.dataset + and impact.time_period == scope.time_period and impact.options_hash == options_hash and impact.status == "computing" ] diff --git a/policyengine_api/services/budget_window_cache.py b/policyengine_api/services/budget_window_cache.py index 9fe255965..c25dbd665 100644 --- a/policyengine_api/services/budget_window_cache.py +++ b/policyengine_api/services/budget_window_cache.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from typing import Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator @@ -19,6 +19,9 @@ ) from policyengine_api.runtime_cache.dependencies import get_runtime_cache_context +if TYPE_CHECKING: + from policyengine_api.runtime_cache.reform_impacts import ReformImpactScope + BUDGET_WINDOW_CACHE_FAMILY = "budget-window" BUDGET_WINDOW_CACHE_SCHEMA_VERSION = 4 BUDGET_WINDOW_STARTING_TTL_SECONDS = 300 @@ -97,33 +100,29 @@ def __init__( def build_key( self, + scope: ReformImpactScope, *, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, options_hash: str | None, api_version: str, ) -> str: """Return the cache key for one budget-window report request. - Every argument is part of the key, so a change to any of them (including the - API version or the options hash) starts a separate report. + Every scope field and argument is part of the key, so a change to any of + them (including the API version or the options hash) starts a separate + report. """ return self.namespace.key( BUDGET_WINDOW_CACHE_FAMILY, BUDGET_WINDOW_CACHE_SCHEMA_VERSION, { "api_version": api_version, - "baseline_policy_id": baseline_policy_id, - "country_id": country_id, - "dataset": dataset, + "baseline_policy_id": scope.baseline_policy_id, + "country_id": scope.country_id, + "dataset": scope.dataset, "options_hash": options_hash, - "reform_policy_id": reform_policy_id, - "region": region, - "time_period": time_period, + "reform_policy_id": scope.reform_policy_id, + "region": scope.region, + "time_period": scope.time_period, }, ) diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 3894262d4..020fbb036 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -57,6 +57,8 @@ from policyengine_api.runtime_cache.core import CacheCoordinationError from policyengine_api.runtime_cache.reform_impacts import ( CachedReformImpact, + ReformImpactIdentity, + ReformImpactScope, ReformImpactStartClaim, ) from policyengine_api.services.budget_window_cache import ( @@ -65,6 +67,7 @@ ) from policyengine_api.services.policy_service import PolicyService from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, ReformImpactHandoffError, ReformImpactsService, ) @@ -659,12 +662,7 @@ def _build_budget_window_cache_key( setup_options: EconomicImpactSetupOptions, ) -> str: return self._budget_window_cache.build_key( - country_id=setup_options.country_id, - reform_policy_id=setup_options.reform_policy_id, - baseline_policy_id=setup_options.baseline_policy_id, - region=setup_options.region, - dataset=setup_options.dataset, - time_period=setup_options.time_period, + scope=self._reform_impact_scope(setup_options), options_hash=setup_options.options_hash, api_version=setup_options.api_version, ) @@ -1121,6 +1119,31 @@ def _resolve_runtime_bundle_for_setup_options( runtime_app_name=setup_options.runtime_app_name, ) + @staticmethod + def _reform_impact_scope( + setup_options: EconomicImpactSetupOptions, + ) -> ReformImpactScope: + """Return the policy pair, region, dataset and period of these options.""" + return ReformImpactScope( + country_id=setup_options.country_id, + reform_policy_id=setup_options.reform_policy_id, + baseline_policy_id=setup_options.baseline_policy_id, + region=setup_options.region, + dataset=setup_options.dataset, + time_period=setup_options.time_period, + ) + + def _reform_impact_identity( + self, + setup_options: EconomicImpactSetupOptions, + ) -> ReformImpactIdentity: + """Return the cache identity of the impact these setup options describe.""" + return ReformImpactIdentity( + scope=self._reform_impact_scope(setup_options), + api_version=setup_options.api_version, + options_hash=setup_options.options_hash, + ) + def _reform_impact_start_claim_scope_arguments( self, setup_options: EconomicImpactSetupOptions, @@ -1129,14 +1152,7 @@ def _reform_impact_start_claim_scope_arguments( msg = "resolved reform-impact options hash is required" raise ValueError(msg) return { - "country_id": setup_options.country_id, - "policy_id": setup_options.reform_policy_id, - "baseline_policy_id": setup_options.baseline_policy_id, - "region": setup_options.region, - "dataset": setup_options.dataset, - "time_period": setup_options.time_period, - "options_hash": setup_options.options_hash, - "api_version": setup_options.api_version, + "identity": self._reform_impact_identity(setup_options), "target": setup_options.target, } @@ -1211,26 +1227,19 @@ def _build_budget_window_progress_message( def _get_previous_impacts( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, + scope: ReformImpactScope, + *, options_hash: str, api_version: str, ) -> list[CachedReformImpact]: """Fetch any previous simulation runs for the given policy reform.""" return self._reform_impacts.get_all_reform_impacts_by_options_hash_prefix( - country_id, - policy_id, - baseline_policy_id, - region, - dataset, - time_period, - options_hash, + ReformImpactIdentity( + scope=scope, + api_version=api_version, + options_hash=options_hash, + ), self._build_options_hash_lookup_pattern(options_hash), - api_version, ) def _get_most_recent_impact( @@ -1243,12 +1252,7 @@ def _get_most_recent_impact( whose hash extends this one (with extra bundle fields) is used. """ previous_impacts = self._get_previous_impacts( - country_id=setup_options.country_id, - policy_id=setup_options.reform_policy_id, - baseline_policy_id=setup_options.baseline_policy_id, - region=setup_options.region, - dataset=setup_options.dataset, - time_period=setup_options.time_period, + self._reform_impact_scope(setup_options), options_hash=setup_options.options_hash, api_version=setup_options.api_version, ) @@ -1810,20 +1814,15 @@ def _set_reform_impact_computing( """Record the impact as "computing" for this run; log and re-raise errors.""" try: self._reform_impacts.set_reform_impact( - country_id=setup_options.country_id, - policy_id=setup_options.reform_policy_id, - baseline_policy_id=setup_options.baseline_policy_id, - region=setup_options.region, - dataset=setup_options.dataset, - time_period=setup_options.time_period, + identity=self._reform_impact_identity(setup_options), + execution=ReformImpactExecution( + execution_id=execution_id, + start_time=datetime.datetime.now(), # noqa: DTZ005 # start_time is a naive column that has always held local time + observability_id=setup_options.observability_id, + ), options=setup_options.options, - options_hash=setup_options.options_hash, status=ImpactStatus.COMPUTING.value, - api_version=setup_options.api_version, reform_impact_json={}, - start_time=datetime.datetime.now(), # noqa: DTZ005 # start_time is a naive column that has always held local time - execution_id=execution_id, - observability_id=setup_options.observability_id, ) except Exception as e: logger.log_struct( @@ -1846,15 +1845,8 @@ def _set_reform_impact_complete( """Record the impact as "ok" with its result JSON; log and re-raise errors.""" try: self._reform_impacts.set_complete_reform_impact( - country_id=setup_options.country_id, - reform_policy_id=setup_options.reform_policy_id, - baseline_policy_id=setup_options.baseline_policy_id, - region=setup_options.region, - dataset=setup_options.dataset, - time_period=setup_options.time_period, - options_hash=setup_options.options_hash, - reform_impact_json=reform_impact_json, execution_id=execution_id, + reform_impact_json=reform_impact_json, ) except Exception as e: logger.log_struct( @@ -1878,15 +1870,8 @@ def _set_reform_impact_error( """Record the impact as "error" with its message; log and re-raise errors.""" try: self._reform_impacts.set_error_reform_impact( - country_id=setup_options.country_id, - policy_id=setup_options.reform_policy_id, - baseline_policy_id=setup_options.baseline_policy_id, - region=setup_options.region, - dataset=setup_options.dataset, - time_period=setup_options.time_period, - options_hash=setup_options.options_hash, - message=message, execution_id=execution_id, + message=message, **({"error_code": error_code} if error_code is not None else {}), ) except Exception as e: diff --git a/policyengine_api/services/reform_impacts_service.py b/policyengine_api/services/reform_impacts_service.py index 813f2a8d9..448904b5a 100644 --- a/policyengine_api/services/reform_impacts_service.py +++ b/policyengine_api/services/reform_impacts_service.py @@ -2,6 +2,7 @@ import contextlib import datetime +from dataclasses import dataclass from typing import Any from policyengine_api.runtime_cache.core import CacheCoordinationError @@ -9,6 +10,8 @@ from policyengine_api.runtime_cache.reform_impacts import ( CachedReformImpact, ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, ReformImpactStartClaim, reform_impact_id, ) @@ -18,6 +21,20 @@ class ReformImpactHandoffError(CacheCoordinationError): """Raised when an accepted simulation cannot be recorded for polling.""" +@dataclass(frozen=True) +class ReformImpactExecution: + """The simulation job behind a cached reform impact. + + ``start_time`` is naive local time, as the historical column held it, and + ``observability_id`` is the diagnostic identifier of the request that + submitted the job. + """ + + execution_id: str + start_time: datetime.datetime | None + observability_id: str | None = None + + class ReformImpactsService: """Preserve historical lookup contracts over an expiring cache repository.""" @@ -37,38 +54,15 @@ def get_recent_reform_impacts( def get_all_reform_impacts( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str, - api_version: str, + identity: ReformImpactIdentity, ) -> list[CachedReformImpact]: """Return cached impacts with exactly this options hash, newest first.""" - return self._cache.matching( - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, - ) + return self._cache.matching(identity) def get_all_reform_impacts_by_options_hash_prefix( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str, + identity: ReformImpactIdentity, options_hash_prefix: str, - api_version: str, ) -> list[CachedReformImpact]: """Return cached impacts matching the options hash or the prefix pattern. @@ -76,42 +70,21 @@ def get_all_reform_impacts_by_options_hash_prefix( first, then the most recently started. """ return self._cache.matching( - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, + identity, options_hash_pattern=options_hash_prefix, ) def claim_reform_impact_start( self, *, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str, - api_version: str, + identity: ReformImpactIdentity, target: str, claim_token: str, observability_id: str, ) -> bool: """Fail closed unless this request atomically owns job submission.""" return self._cache.claim_start( - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, + identity, target=target, claim_token=claim_token, observability_id=observability_id, @@ -120,40 +93,16 @@ def claim_reform_impact_start( def get_reform_impact_start_claim( self, *, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str, - api_version: str, + identity: ReformImpactIdentity, target: str, ) -> ReformImpactStartClaim | None: """Return the current submission owner and diagnostic identifier.""" - return self._cache.get_start_claim( - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, - target=target, - ) + return self._cache.get_start_claim(identity, target=target) def release_reform_impact_start( self, *, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str, - api_version: str, + identity: ReformImpactIdentity, target: str, claim_token: str, observability_id: str, @@ -161,14 +110,7 @@ def release_reform_impact_start( """Best-effort release; an unavailable cache safely falls back to expiry.""" with contextlib.suppress(CacheCoordinationError): self._cache.release_start( - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - api_version=api_version, - options_hash=options_hash, + identity, target=target, claim_token=claim_token, observability_id=observability_id, @@ -176,44 +118,37 @@ def release_reform_impact_start( def set_reform_impact( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, + identity: ReformImpactIdentity, + execution: ReformImpactExecution, + *, options: dict[str, Any], - options_hash: str | None, status: str, - api_version: str, reform_impact_json: dict[str, Any], - start_time: datetime.datetime, - execution_id: str, - observability_id: str | None = None, ) -> CachedReformImpact: """Cache a newly submitted impact by execution ID so later requests can poll it. Raise ReformImpactHandoffError if the write fails, because the accepted job could not otherwise be found again. """ + scope = identity.scope impact = CachedReformImpact( - reform_impact_id=reform_impact_id(execution_id), - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, + reform_impact_id=reform_impact_id(execution.execution_id), + country_id=scope.country_id, + reform_policy_id=scope.reform_policy_id, + baseline_policy_id=scope.baseline_policy_id, + region=scope.region, + dataset=scope.dataset, + time_period=scope.time_period, options_json=options, - options_hash=options_hash, + options_hash=identity.options_hash, status=status, - api_version=api_version, + api_version=identity.api_version, reform_impact_json=reform_impact_json, message=None, - start_time=start_time, + start_time=execution.start_time, end_time=None, - execution_id=execution_id, - observability_id=observability_id, + execution_id=execution.execution_id, + observability_id=execution.observability_id, ) if not self._cache.set(impact): msg = "submitted reform-impact execution could not be stored" @@ -222,54 +157,25 @@ def set_reform_impact( def delete_reform_impact( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, + scope: ReformImpactScope, options_hash: str, ) -> None: """Remove this scope's in-flight (computing) impacts, ignoring cache errors.""" - self._cache.delete_matching_computing( - country_id=country_id, - reform_policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, - time_period=time_period, - options_hash=options_hash, - ) + self._cache.delete_matching_computing(scope, options_hash=options_hash) def set_error_reform_impact( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str | None, - message: str, execution_id: str, + *, + message: str, error_code: str | None = None, ) -> CachedReformImpact | None: """Mark this execution's cached impact as failed. - Only ``execution_id`` selects the record; the scope arguments are kept for the - historical signature and ignored. An ``error_code`` records a typed, terminal - failure and clears the worker handle. Return the updated impact, or None if - it is missing or the write failed. + An ``error_code`` records a typed, terminal failure and clears the worker + handle. Return the updated impact, or None if it is missing or the write + failed. """ - del ( - country_id, - policy_id, - baseline_policy_id, - region, - dataset, - time_period, - options_hash, - ) return self._cache.update( execution_id, status="error", @@ -281,30 +187,14 @@ def set_error_reform_impact( def set_complete_reform_impact( self, - country_id: str, - reform_policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, - time_period: str, - options_hash: str | None, - reform_impact_json: dict[str, Any], execution_id: str, + *, + reform_impact_json: dict[str, Any], ) -> CachedReformImpact | None: """Store the finished result on this execution's cached impact. - Only ``execution_id`` selects the record; the scope arguments are ignored. Return the updated impact, or None if it is missing or the write failed. """ - del ( - country_id, - reform_policy_id, - baseline_policy_id, - region, - dataset, - time_period, - options_hash, - ) return self._cache.update( execution_id, status="ok", diff --git a/tests/integration/test_runtime_cache_redis.py b/tests/integration/test_runtime_cache_redis.py index 2b114f3cc..8e7e6cbef 100644 --- a/tests/integration/test_runtime_cache_redis.py +++ b/tests/integration/test_runtime_cache_redis.py @@ -19,6 +19,8 @@ from policyengine_api.runtime_cache.reform_impacts import ( CachedReformImpact, ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, reform_impact_id, ) @@ -172,14 +174,18 @@ def test_reform_submission_claim_is_shared_across_connections(redis_pair) -> Non writer = ReformImpactCache(first, namespace) contender = ReformImpactCache(second, namespace) arguments = { - "country_id": "us", - "reform_policy_id": 2, - "baseline_policy_id": 1, - "region": "us", - "dataset": "default", - "time_period": "2026", - "api_version": "v1", - "options_hash": "resolved-hash", + "identity": ReformImpactIdentity( + scope=ReformImpactScope( + country_id="us", + reform_policy_id=2, + baseline_policy_id=1, + region="us", + dataset="default", + time_period="2026", + ), + api_version="v1", + options_hash="resolved-hash", + ), "target": "general", } writer_observability_id = "00000000-0000-4000-8000-000000000001" diff --git a/tests/unit/routes/test_economy_failure_replay.py b/tests/unit/routes/test_economy_failure_replay.py index 8ade23ff4..5532aab04 100644 --- a/tests/unit/routes/test_economy_failure_replay.py +++ b/tests/unit/routes/test_economy_failure_replay.py @@ -13,7 +13,10 @@ from policyengine_api.runtime_cache.reform_impacts import ReformImpactCache from policyengine_api.services import economy_service as economy_module from policyengine_api.services.economy_service import EconomyService -from policyengine_api.services.reform_impacts_service import ReformImpactsService +from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, + ReformImpactsService, +) SELECTION = { "forecast_content_sha256": "a" * 64, @@ -68,19 +71,11 @@ def resolve_app_name( ) service._resolve_runtime_bundle_for_setup_options(setup) impacts.set_reform_impact( - country_id=setup.country_id, - policy_id=setup.reform_policy_id, - baseline_policy_id=setup.baseline_policy_id, - region=setup.region, - dataset=setup.dataset, - time_period=setup.time_period, + service._reform_impact_identity(setup), + ReformImpactExecution(execution_id=EXECUTION_ID, start_time=None), options=setup.options, - options_hash=setup.options_hash, status="computing", - api_version=setup.api_version, reform_impact_json={}, - start_time=None, - execution_id=EXECUTION_ID, ) monkeypatch.setattr(economy_routes, "economy_service", service) logger = Mock() diff --git a/tests/unit/routes/test_reform_impact_routes.py b/tests/unit/routes/test_reform_impact_routes.py index 29ffbf65e..efc3f070d 100644 --- a/tests/unit/routes/test_reform_impact_routes.py +++ b/tests/unit/routes/test_reform_impact_routes.py @@ -19,9 +19,14 @@ from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.reform_impacts import ( ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, reform_impact_id, ) -from policyengine_api.services.reform_impacts_service import ReformImpactsService +from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, + ReformImpactsService, +) COMPLETED_AT = datetime(2026, 1, 1, 1) @@ -56,31 +61,27 @@ def _seed_reform_impacts(service: ReformImpactsService, n: int) -> None: for i in range(n): execution_id = f"exec-{i}" service.set_reform_impact( - baseline_policy_id=i + 1, - policy_id=i + 2, - country_id="us", - region="us", - dataset="custom_dataset", - time_period="2025", + ReformImpactIdentity( + scope=ReformImpactScope( + country_id="us", + reform_policy_id=i + 2, + baseline_policy_id=i + 1, + region="us", + dataset="custom_dataset", + time_period="2025", + ), + api_version="1.0.0", + options_hash=f"hash-{i}", + ), + ReformImpactExecution( + execution_id=execution_id, + start_time=datetime(2026, 1, 1, 0, i // 60, i % 60), + ), options={}, - options_hash=f"hash-{i}", - api_version="1.0.0", - reform_impact_json={}, status="computing", - start_time=datetime(2026, 1, 1, 0, i // 60, i % 60), - execution_id=execution_id, - ) - service.set_complete_reform_impact( - country_id="us", - reform_policy_id=i + 2, - baseline_policy_id=i + 1, - region="us", - dataset="custom_dataset", - time_period="2025", - options_hash=f"hash-{i}", reform_impact_json={}, - execution_id=execution_id, ) + service.set_complete_reform_impact(execution_id, reform_impact_json={}) def test_get_simulations_default_limit_caps_at_100(reform_impacts_service): diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index d0797633a..cc4878c5d 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -13,7 +13,10 @@ from policyengine_api.services import economy_service as economy_module from policyengine_api.services.budget_window_cache import BudgetWindowCache from policyengine_api.services.economy_service import EconomyService -from policyengine_api.services.reform_impacts_service import ReformImpactsService +from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, + ReformImpactsService, +) SELECTION = { "forecast_content_sha256": "a" * 64, @@ -171,19 +174,11 @@ def transport(request): else: service._resolve_runtime_bundle_for_setup_options(setup) annual_impacts.set_reform_impact( - country_id=setup.country_id, - policy_id=setup.reform_policy_id, - baseline_policy_id=setup.baseline_policy_id, - region=setup.region, - dataset=setup.dataset, - time_period=setup.time_period, + service._reform_impact_identity(setup), + ReformImpactExecution(execution_id=job_id, start_time=None), options=setup.options, - options_hash=setup.options_hash, status="computing", - api_version=setup.api_version, reform_impact_json={}, - start_time=None, - execution_id=job_id, ) url = "/us/economy/1/over/2" query = {"time_period": "2036"} @@ -338,19 +333,11 @@ def transport(request): else: service._resolve_runtime_bundle_for_setup_options(setup) annual_impacts.set_reform_impact( - country_id=setup.country_id, - policy_id=setup.reform_policy_id, - baseline_policy_id=setup.baseline_policy_id, - region=setup.region, - dataset=setup.dataset, - time_period=setup.time_period, + service._reform_impact_identity(setup), + ReformImpactExecution(execution_id=job_id, start_time=None), options=setup.options, - options_hash=setup.options_hash, status="ok", - api_version=setup.api_version, reform_impact_json=segmented_result("2036", "reform"), - start_time=None, - execution_id=job_id, ) url = "/us/economy/1/over/2" query = {"time_period": "2036"} diff --git a/tests/unit/runtime_cache/test_reform_impacts.py b/tests/unit/runtime_cache/test_reform_impacts.py index faea70023..2981ca183 100644 --- a/tests/unit/runtime_cache/test_reform_impacts.py +++ b/tests/unit/runtime_cache/test_reform_impacts.py @@ -15,6 +15,8 @@ REFORM_IMPACT_START_CLAIM_TTL_SECONDS, CachedReformImpact, ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, reform_impact_id, ) @@ -50,20 +52,23 @@ def _impact(execution_id: str, options_hash: str, day: int) -> CachedReformImpac ) -def _claim_arguments(**changes): - values = { - "country_id": "us", - "reform_policy_id": 2, - "baseline_policy_id": 1, - "region": "us", - "dataset": "default", - "time_period": "2026", - "api_version": "v1", - "options_hash": "hash", - "target": "general", +SCOPE = ReformImpactScope( + country_id="us", + reform_policy_id=2, + baseline_policy_id=1, + region="us", + dataset="default", + time_period="2026", +) + + +def _claim_arguments(*, target="general"): + return { + "identity": ReformImpactIdentity( + scope=SCOPE, api_version="v1", options_hash="hash" + ), + "target": target, } - values.update(changes) - return values def test_reform_impact_start_claim_is_atomic_exact_ttl_and_token_safe() -> None: @@ -168,14 +173,9 @@ def test_reform_impact_indexes_are_bounded_expiring_and_query_compatible( "compatible", ] results = cache.matching( - country_id="us", - reform_policy_id=2, - baseline_policy_id=1, - region="us", - dataset="default", - time_period="2026", - api_version="v1", - options_hash="hash-compatible", + ReformImpactIdentity( + scope=SCOPE, api_version="v1", options_hash="hash-compatible" + ), options_hash_pattern="hash-%", ) assert [value.execution_id for value in results] == [ @@ -241,15 +241,7 @@ def test_reform_impact_updates_and_deletes_only_matching_computing_values() -> N ) assert completed is not None assert completed.status == "ok" - cache.delete_matching_computing( - country_id="us", - reform_policy_id=2, - baseline_policy_id=1, - region="us", - dataset="default", - time_period="2026", - options_hash="delete-hash", - ) + cache.delete_matching_computing(SCOPE, options_hash="delete-hash") assert cache.get_by_execution_id("delete") is None assert cache.get_by_execution_id("retain") is not None diff --git a/tests/unit/runtime_cache/test_runtime_cache_contracts.py b/tests/unit/runtime_cache/test_runtime_cache_contracts.py index ef291b9ca..4667926ed 100644 --- a/tests/unit/runtime_cache/test_runtime_cache_contracts.py +++ b/tests/unit/runtime_cache/test_runtime_cache_contracts.py @@ -14,14 +14,19 @@ from policyengine_api.runtime_cache.reform_impacts import ( CachedReformImpact, ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, reform_impact_id, ) from policyengine_api.services.budget_window_cache import BudgetWindowCache -from policyengine_api.services.reform_impacts_service import ReformImpactsService +from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, + ReformImpactsService, +) NAMESPACE = CacheNamespace("test", "api") OBSERVABILITY_ID = "00000000-0000-4000-8000-000000000001" -CLAIM_TOKEN = "owner" # noqa: S105 # an opaque claim id, not a secret +CLAIM_TOKEN = "owner" SCOPE = { "country_id": "us", "reform_policy_id": 22, @@ -42,14 +47,20 @@ def _service() -> ReformImpactsService: return ReformImpactsService(_cache()) -def _claim_arguments(**changes: object) -> dict: - return { - **SCOPE, - "api_version": "v7", - "options_hash": "[region=state/ca&dataset=default]", - "target": "general", - **changes, - } +OPTIONS_HASH = "[region=state/ca&dataset=default]" + + +def _identity(**changes: object) -> ReformImpactIdentity: + values = {**SCOPE, "api_version": "v7", "options_hash": OPTIONS_HASH, **changes} + return ReformImpactIdentity( + scope=ReformImpactScope(**{field: values[field] for field in SCOPE}), + api_version=values["api_version"], + options_hash=values["options_hash"], + ) + + +def _claim_arguments(*, target: str = "general", **changes: object) -> dict: + return {"identity": _identity(**changes), "target": target} def test_start_claim_key_is_stable(): @@ -87,8 +98,8 @@ def test_budget_window_key_is_stable(): cache = BudgetWindowCache(InMemoryCacheBackend(), NAMESPACE) key = cache.build_key( - **SCOPE, - options_hash="[region=state/ca&dataset=default]", + ReformImpactScope(**SCOPE), + options_hash=OPTIONS_HASH, api_version="v7", ) @@ -130,7 +141,7 @@ def _stored_impact(**changes: object) -> CachedReformImpact: "reform_impact_json": {}, "status": "computing", "message": None, - "start_time": datetime(2026, 1, 2, 3, 4, 5), # noqa: DTZ001 # reform_impact start_time is a naive column + "start_time": datetime(2026, 1, 2, 3, 4, 5), "end_time": None, "execution_id": "job-1", "error_code": None, @@ -144,20 +155,15 @@ def test_set_reform_impact_stores_every_field_from_its_arguments(): service = _service() stored = service.set_reform_impact( - country_id="us", - policy_id=22, - baseline_policy_id=11, - region="state/ca", - dataset="default", - time_period="2026", + _identity(), + ReformImpactExecution( + execution_id="job-1", + start_time=datetime(2026, 1, 2, 3, 4, 5), + observability_id=OBSERVABILITY_ID, + ), options={"region": "state/ca"}, - options_hash="[region=state/ca&dataset=default]", status="computing", - api_version="v7", reform_impact_json={}, - start_time=datetime(2026, 1, 2, 3, 4, 5), # noqa: DTZ001 # reform_impact start_time is a naive column - execution_id="job-1", - observability_id=OBSERVABILITY_ID, ) assert stored == _stored_impact() @@ -168,36 +174,12 @@ def test_lookups_select_by_every_scope_field(): service = _service() service._cache.set(_stored_impact()) - exact = service.get_all_reform_impacts( - "us", - 22, - 11, - "state/ca", - "default", - "2026", - "[region=state/ca&dataset=default]", - "v7", - ) + exact = service.get_all_reform_impacts(_identity()) by_prefix = service.get_all_reform_impacts_by_options_hash_prefix( - "us", - 22, - 11, - "state/ca", - "default", - "2026", - "[region=state/ca]", - "[region=state/ca&%", - "v7", + _identity(options_hash="[region=state/ca]"), "[region=state/ca&%" ) swapped_policies = service.get_all_reform_impacts( - "us", - 11, - 22, - "state/ca", - "default", - "2026", - "[region=state/ca&dataset=default]", - "v7", + _identity(reform_policy_id=11, baseline_policy_id=22) ) assert exact == [_stored_impact()] @@ -211,26 +193,11 @@ def test_transitions_update_only_the_execution_record(): service._cache.set(_stored_impact(execution_id="job-2", options_hash="[two]")) completed = service.set_complete_reform_impact( - "uk", - 1, - 2, - "uk", - "ignored", - "1999", - "[ignored]", - {"budget": 1}, - "job-1", + "job-1", reform_impact_json={"budget": 1} ) failed = service.set_error_reform_impact( - "uk", - 1, - 2, - "uk", - "ignored", - "1999", - "[ignored]", - "worker failed", "job-2", + message="worker failed", error_code="SPM_CONFIGURATION_UNAVAILABLE", ) @@ -268,15 +235,7 @@ def test_delete_keeps_records_differing_in_any_field(changes): service = _service() service._cache.set(_stored_impact(**changes)) - service.delete_reform_impact( - "us", - 22, - 11, - "state/ca", - "default", - "2026", - "[region=state/ca&dataset=default]", - ) + service.delete_reform_impact(ReformImpactScope(**SCOPE), OPTIONS_HASH) assert len(service.get_recent_reform_impacts(5)) == 1 @@ -285,15 +244,7 @@ def test_delete_ignores_the_api_version(): service = _service() service._cache.set(_stored_impact(api_version="v0")) - service.delete_reform_impact( - "us", - 22, - 11, - "state/ca", - "default", - "2026", - "[region=state/ca&dataset=default]", - ) + service.delete_reform_impact(ReformImpactScope(**SCOPE), OPTIONS_HASH) assert service.get_recent_reform_impacts(5) == [] @@ -305,13 +256,7 @@ def test_delete_swallows_a_failed_cache_write(): backend.pipeline.side_effect = RuntimeError("redis unavailable") ReformImpactsService(cache).delete_reform_impact( - "us", - 22, - 11, - "state/ca", - "default", - "2026", - "[region=state/ca&dataset=default]", + ReformImpactScope(**SCOPE), OPTIONS_HASH ) @@ -353,17 +298,7 @@ def test_unreachable_claim_store_fails_closed(): def test_service_claim_round_trip_uses_the_same_key(): service = _service() - arguments = { - "country_id": "us", - "policy_id": 22, - "baseline_policy_id": 11, - "region": "state/ca", - "dataset": "default", - "time_period": "2026", - "options_hash": "[region=state/ca&dataset=default]", - "api_version": "v7", - "target": "general", - } + arguments = _claim_arguments() assert service.claim_reform_impact_start( **arguments, claim_token=CLAIM_TOKEN, observability_id=OBSERVABILITY_ID diff --git a/tests/unit/services/test_budget_window_cache.py b/tests/unit/services/test_budget_window_cache.py index eff158f05..ce13dad67 100644 --- a/tests/unit/services/test_budget_window_cache.py +++ b/tests/unit/services/test_budget_window_cache.py @@ -8,6 +8,7 @@ encode_envelope, ) from policyengine_api.runtime_cache.fake import InMemoryCacheBackend +from policyengine_api.runtime_cache.reform_impacts import ReformImpactScope from policyengine_api.services.budget_window_cache import ( BUDGET_WINDOW_BATCH_TTL_SECONDS, BUDGET_WINDOW_CACHE_SCHEMA_VERSION, @@ -67,25 +68,19 @@ def test_cache_state_rejects_malformed_documents(payload): def test_build_key_is_stable_for_request_identity(): cache = BudgetWindowCache(client=FakeRedis()) - first = cache.build_key( + scope = ReformImpactScope( country_id="us", reform_policy_id=123, baseline_policy_id=456, region="us", dataset="custom_dataset", time_period="budget_window:2026:10", - options_hash="[option=value]", - api_version="e1cache01", + ) + first = cache.build_key( + scope, options_hash="[option=value]", api_version="e1cache01" ) second = cache.build_key( - country_id="us", - reform_policy_id=123, - baseline_policy_id=456, - region="us", - dataset="custom_dataset", - time_period="budget_window:2026:10", - options_hash="[option=value]", - api_version="e1cache01", + scope, options_hash="[option=value]", api_version="e1cache01" ) assert first == second @@ -116,7 +111,18 @@ def test_previous_schema_cache_identity_is_not_read(): {"status": "completed", "result": {"legacy": True}}, ), ) - current_key = cache.build_key(**identity) + current_key = cache.build_key( + ReformImpactScope( + country_id="us", + reform_policy_id=2, + baseline_policy_id=1, + region="us", + dataset="default", + time_period="budget_window:2026:2", + ), + options_hash="[spm=canonical]", + api_version="v1", + ) assert current_key != old_key assert cache.get_state(current_key) is None @@ -170,17 +176,20 @@ def test_spm_validation_failure_round_trips_for_only_its_selection(monkeypatch): backend = FakeRedis() cache = BudgetWindowCache(client=backend) error = {"code": "SPM_YEAR_UNAVAILABLE", "message": "No forecast for 2036"} - identity = { - "country_id": "us", - "reform_policy_id": 1, - "baseline_policy_id": 2, - "region": "us", - "dataset": "default", - "time_period": "budget_window:2035:2", - "api_version": "v1", - } - failed_key = cache.build_key(**identity, options_hash="canonical-selection-a") - other_key = cache.build_key(**identity, options_hash="canonical-selection-b") + scope = ReformImpactScope( + country_id="us", + reform_policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + time_period="budget_window:2035:2", + ) + failed_key = cache.build_key( + scope, options_hash="canonical-selection-a", api_version="v1" + ) + other_key = cache.build_key( + scope, options_hash="canonical-selection-b", api_version="v1" + ) assert cache.set_terminal_error(failed_key, error, "obs-1") diff --git a/tests/unit/services/test_direct_orm_local_analysis.py b/tests/unit/services/test_direct_orm_local_analysis.py index a0ef9d91f..cd0642f6f 100644 --- a/tests/unit/services/test_direct_orm_local_analysis.py +++ b/tests/unit/services/test_direct_orm_local_analysis.py @@ -7,8 +7,13 @@ from policyengine_api.runtime_cache.reform_impacts import ( CachedReformImpact, ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, +) +from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, + ReformImpactsService, ) -from policyengine_api.services.reform_impacts_service import ReformImpactsService def _context(): @@ -20,19 +25,25 @@ def test_reform_impact_service_writes_typed_expiring_cache_entity() -> None: impact = ReformImpactsService( ReformImpactCache(backend, namespace) ).set_reform_impact( - country_id="us", - policy_id=2, - baseline_policy_id=1, - region="us", - dataset="default", - time_period="2026", + ReformImpactIdentity( + scope=ReformImpactScope( + country_id="us", + reform_policy_id=2, + baseline_policy_id=1, + region="us", + dataset="default", + time_period="2026", + ), + api_version="1", + options_hash="hash", + ), + ReformImpactExecution( + execution_id="job", + start_time=datetime(2026, 1, 1), + ), options={"dataset": "default"}, - options_hash="hash", status="computing", - api_version="1", reform_impact_json={}, - start_time=datetime(2026, 1, 1), - execution_id="job", ) assert isinstance(impact, CachedReformImpact) diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index 943a76d5d..caf988bc8 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -10,7 +10,11 @@ SimulationRunProgress, ) from policyengine_api.runtime_cache.core import CacheCoordinationError -from policyengine_api.runtime_cache.reform_impacts import ReformImpactStartClaim +from policyengine_api.runtime_cache.reform_impacts import ( + ReformImpactIdentity, + ReformImpactScope, + ReformImpactStartClaim, +) from policyengine_api.services.budget_window_cache import BudgetWindowCacheState from policyengine_api.services.economy_service import ( BUDGET_WINDOW_MAX_END_YEAR, @@ -437,8 +441,8 @@ def test__given_no_previous_impact__creates_new_simulation( mock_reform_impacts_service.claim_reform_impact_start.assert_called_once() assert ( mock_reform_impacts_service.claim_reform_impact_start.call_args.kwargs[ - "options_hash" - ] + "identity" + ].options_hash == MOCK_OPTIONS_HASH ) mock_reform_impacts_service.release_reform_impact_start.assert_called_once_with( @@ -450,7 +454,7 @@ def test__given_no_previous_impact__creates_new_simulation( ) assert write_values["options"] == resolved_options(MOCK_OPTIONS) assert write_values["reform_impact_json"] == {} - assert write_values["observability_id"] == MOCK_OBSERVABILITY_ID + assert write_values["execution"].observability_id == MOCK_OBSERVABILITY_ID @pytest.mark.usefixtures( "mock_country_package_versions", @@ -896,17 +900,21 @@ def test__given_runtime_cache_version__uses_versioned_economy_cache_key( economy_service.get_economic_impact(**base_params) hash_prefix_lookup(mock_reform_impacts_service).assert_called_once_with( - MOCK_COUNTRY_ID, - MOCK_POLICY_ID, - MOCK_BASELINE_POLICY_ID, - MOCK_REGION, - MOCK_DATASET, - MOCK_TIME_PERIOD, - MOCK_LOOKUP_OPTIONS_HASH, + ReformImpactIdentity( + scope=ReformImpactScope( + country_id=MOCK_COUNTRY_ID, + reform_policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region=MOCK_REGION, + dataset=MOCK_DATASET, + time_period=MOCK_TIME_PERIOD, + ), + api_version=cache_version, + options_hash=MOCK_LOOKUP_OPTIONS_HASH, + ), economy_service._build_options_hash_lookup_pattern( MOCK_LOOKUP_OPTIONS_HASH ), - cache_version, ) @pytest.mark.usefixtures( @@ -928,15 +936,17 @@ def test__given_default_dataset__queries_previous_impacts_with_resolved_bundle( economy_service.get_economic_impact(**base_params) - call_args = hash_prefix_lookup(mock_reform_impacts_service).call_args.args - assert call_args[4] == MOCK_DATASET - assert call_args[6] == MOCK_LOOKUP_OPTIONS_HASH - assert call_args[7] == economy_service._build_options_hash_lookup_pattern( + identity, lookup_pattern = hash_prefix_lookup( + mock_reform_impacts_service + ).call_args.args + assert identity.scope.dataset == MOCK_DATASET + assert identity.options_hash == MOCK_LOOKUP_OPTIONS_HASH + assert lookup_pattern == economy_service._build_options_hash_lookup_pattern( MOCK_LOOKUP_OPTIONS_HASH ) - assert "data\\_version" not in call_args[7] - assert "policyengine\\_version=3.4.0" in call_args[7] - assert "runtime_app_name" not in call_args[7] + assert "data\\_version" not in lookup_pattern + assert "policyengine\\_version=3.4.0" in lookup_pattern + assert "runtime_app_name" not in lookup_pattern @pytest.mark.usefixtures( "mock_country_package_versions", @@ -1884,7 +1894,7 @@ def test__given_runtime_cache_version__uses_versioned_budget_window_cache_key( assert result.status == ImpactStatus.COMPUTING cache_key_kwargs = mock_budget_window_cache.build_key.call_args.kwargs - assert cache_key_kwargs["time_period"] == "budget_window:2026:3" + assert cache_key_kwargs["scope"].time_period == "budget_window:2026:3" assert cache_key_kwargs["api_version"] == cache_version def test__given_reordered_options__uses_same_budget_window_cache_identity( @@ -1939,9 +1949,9 @@ def test__given_legacy_us_region__normalizes_before_building_cache_key( result = economy_service.get_budget_window_economic_impact(**base_params) assert result.status == ImpactStatus.COMPUTING - assert mock_budget_window_cache.build_key.call_args.kwargs["region"] == ( - "state/ca" - ) + assert mock_budget_window_cache.build_key.call_args.kwargs[ + "scope" + ].region == ("state/ca") def test__given_unexpected_batch_status__raises_value_error( self, @@ -2009,28 +2019,27 @@ def test_given_valid_parameters_calls_service_correctly( mock_reform_impacts_service ).return_value = expected_impacts + scope = ReformImpactScope( + country_id=MOCK_COUNTRY_ID, + reform_policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region=MOCK_REGION, + dataset=MOCK_DATASET, + time_period=MOCK_TIME_PERIOD, + ) + result = economy_service._get_previous_impacts( - MOCK_COUNTRY_ID, - MOCK_POLICY_ID, - MOCK_BASELINE_POLICY_ID, - MOCK_REGION, - MOCK_DATASET, - MOCK_TIME_PERIOD, - MOCK_OPTIONS_HASH, - MOCK_API_VERSION, + scope, options_hash=MOCK_OPTIONS_HASH, api_version=MOCK_API_VERSION ) assert result == expected_impacts hash_prefix_lookup(mock_reform_impacts_service).assert_called_once_with( - MOCK_COUNTRY_ID, - MOCK_POLICY_ID, - MOCK_BASELINE_POLICY_ID, - MOCK_REGION, - MOCK_DATASET, - MOCK_TIME_PERIOD, - MOCK_OPTIONS_HASH, + ReformImpactIdentity( + scope=scope, + api_version=MOCK_API_VERSION, + options_hash=MOCK_OPTIONS_HASH, + ), economy_service._build_options_hash_lookup_pattern(MOCK_OPTIONS_HASH), - MOCK_API_VERSION, ) class TestGetMostRecentImpact: diff --git a/tests/unit/services/test_economy_service_request_plumbing.py b/tests/unit/services/test_economy_service_request_plumbing.py index 5028dbe1b..3db06587f 100644 --- a/tests/unit/services/test_economy_service_request_plumbing.py +++ b/tests/unit/services/test_economy_service_request_plumbing.py @@ -15,6 +15,8 @@ from policyengine_api.runtime_cache.reform_impacts import ( CachedReformImpact, ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, reform_impact_id, ) from policyengine_api.services.budget_window_cache import BudgetWindowCache @@ -221,28 +223,31 @@ def test_annual_submission_stores_record_and_sends_payload( # The submission claim is released once the execution pointer is stored. assert ( economy_service._reform_impacts.get_reform_impact_start_claim( - country_id="us", - policy_id=MOCK_POLICY_ID, - baseline_policy_id=MOCK_BASELINE_POLICY_ID, - region="state/ca", - dataset="default", - time_period="2025", - options_hash=MOCK_OPTIONS_HASH, - api_version=CACHE_VERSION, + identity=ReformImpactIdentity( + scope=_scope(time_period="2025"), + api_version=CACHE_VERSION, + options_hash=MOCK_OPTIONS_HASH, + ), target="cliff", ) is None ) -def _budget_window_key(service: EconomyService) -> str: - return service._budget_window_cache.build_key( +def _scope(time_period: str) -> ReformImpactScope: + return ReformImpactScope( country_id="us", reform_policy_id=MOCK_POLICY_ID, baseline_policy_id=MOCK_BASELINE_POLICY_ID, region="state/ca", dataset="default", - time_period="budget_window:2026:3", + time_period=time_period, + ) + + +def _budget_window_key(service: EconomyService) -> str: + return service._budget_window_cache.build_key( + _scope(time_period="budget_window:2026:3"), options_hash=MOCK_LOOKUP_OPTIONS_HASH, api_version=CACHE_VERSION, ) diff --git a/tests/unit/services/test_reform_impacts_service.py b/tests/unit/services/test_reform_impacts_service.py index 84acb781b..00f7af708 100644 --- a/tests/unit/services/test_reform_impacts_service.py +++ b/tests/unit/services/test_reform_impacts_service.py @@ -5,8 +5,13 @@ from policyengine_api.runtime_cache.core import CacheNamespace from policyengine_api.runtime_cache.fake import InMemoryCacheBackend -from policyengine_api.runtime_cache.reform_impacts import ReformImpactCache +from policyengine_api.runtime_cache.reform_impacts import ( + ReformImpactCache, + ReformImpactIdentity, + ReformImpactScope, +) from policyengine_api.services.reform_impacts_service import ( + ReformImpactExecution, ReformImpactHandoffError, ReformImpactsService, ) @@ -14,6 +19,14 @@ # Claim tokens are opaque ids that tell competing workers apart. OWNER_CLAIM_TOKEN = "owner" CONTENDER_CLAIM_TOKEN = "contender" +SCOPE = ReformImpactScope( + country_id="us", + reform_policy_id=2, + baseline_policy_id=1, + region="us", + dataset="default", + time_period="2026", +) @pytest.fixture @@ -34,19 +47,14 @@ def _create_impact( day: int, ): return service.set_reform_impact( - country_id="us", - policy_id=2, - baseline_policy_id=1, - region="us", - dataset="default", - time_period="2026", + ReformImpactIdentity(scope=SCOPE, api_version="1", options_hash=options_hash), + ReformImpactExecution( + execution_id=execution_id, + start_time=datetime(2026, 1, day), + ), options={"hash": options_hash}, - options_hash=options_hash, status="computing", - api_version="1", reform_impact_json={}, - start_time=datetime(2026, 1, day), - execution_id=execution_id, ) @@ -89,14 +97,9 @@ def test_get_recent_reform_impacts_orders_and_limits_results(service): def test_reform_impact_start_claim_is_exclusive_and_releasable(service): arguments = { - "country_id": "us", - "policy_id": 2, - "baseline_policy_id": 1, - "region": "us", - "dataset": "default", - "time_period": "2026", - "options_hash": "resolved-hash", - "api_version": "1", + "identity": ReformImpactIdentity( + scope=SCOPE, api_version="1", options_hash="resolved-hash" + ), "target": "general", } owner_observability_id = "00000000-0000-4000-8000-000000000001" @@ -141,26 +144,12 @@ def test_reform_impact_service_round_trips_models_and_transitions(service): day=2, ) - exact_results = service.get_all_reform_impacts( - "us", - 2, - 1, - "us", - "default", - "2026", - "hash-exact", - "1", + exact_identity = ReformImpactIdentity( + scope=SCOPE, api_version="1", options_hash="hash-exact" ) + exact_results = service.get_all_reform_impacts(exact_identity) compatible_results = service.get_all_reform_impacts_by_options_hash_prefix( - "us", - 2, - 1, - "us", - "default", - "2026", - "hash-exact", - "hash-%", - "1", + exact_identity, "hash-%" ) assert [impact.reform_impact_id for impact in exact_results] == [ @@ -171,27 +160,9 @@ def test_reform_impact_service_round_trips_models_and_transitions(service): compatible.reform_impact_id, ] completed = service.set_complete_reform_impact( - "us", - 2, - 1, - "us", - "default", - "2026", - "hash-exact", - {"result": 1}, - "exact-job", - ) - failed = service.set_error_reform_impact( - "us", - 2, - 1, - "us", - "default", - "2026", - "hash-compatible", - "failed", - "compatible-job", + "exact-job", reform_impact_json={"result": 1} ) + failed = service.set_error_reform_impact("compatible-job", message="failed") assert completed.status == "ok" assert completed.reform_impact_json == {"result": 1} @@ -215,15 +186,7 @@ def test_reform_impact_service_deletes_only_matching_computing_rows( day=2, ) - service.delete_reform_impact( - "us", - 2, - 1, - "us", - "default", - "2026", - "delete-hash", - ) + service.delete_reform_impact(SCOPE, "delete-hash") remaining = service.get_recent_reform_impacts(10) assert all(impact.execution_id != "delete-job" for impact in remaining) @@ -233,17 +196,4 @@ def test_reform_impact_service_deletes_only_matching_computing_rows( def test_reform_impact_transitions_return_none_for_missing_execution(service): - assert ( - service.set_error_reform_impact( - "us", - 2, - 1, - "us", - "default", - "2026", - "hash", - "missing", - "missing-job", - ) - is None - ) + assert service.set_error_reform_impact("missing-job", message="missing") is None From 0c41515ad41b0a14b57f3f80059ae9f75d0f88fb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:50:25 +0400 Subject: [PATCH 06/10] Split the economy service's request, budget-window and impact flows Pass an annual or budget-window request as an EconomicImpactRequest, which lives in its own module so routes need not load the service to build one. Split the budget-window request into stored-state replay, batch polling and claimed submission steps, and impact creation into reuse, stored-impact and claim-and-submit steps. Group the options-hash bundle fields into RuntimeBundleIdentity and the budget-window year lists into BudgetWindowYearProgress, build simulation options from the setup options, and name the number of computing years a progress message lists. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/routes/economy_routes.py | 39 +- .../services/economic_impact_request.py | 23 + policyengine_api/services/economy_service.py | 775 ++++++++++-------- .../test_economy_budget_window_routes.py | 22 +- .../routes/test_economy_failure_replay.py | 17 +- tests/unit/routes/test_economy_spm_query.py | 11 +- .../routes/test_spm_year_worker_polling.py | 33 +- tests/unit/services/test_economy_service.py | 215 ++--- .../test_economy_service_request_plumbing.py | 64 +- tests/unit/services/test_worker_spm.py | 70 +- 10 files changed, 754 insertions(+), 515 deletions(-) create mode 100644 policyengine_api/services/economic_impact_request.py diff --git a/policyengine_api/routes/economy_routes.py b/policyengine_api/routes/economy_routes.py index 5ab331b4e..425fa342a 100644 --- a/policyengine_api/routes/economy_routes.py +++ b/policyengine_api/routes/economy_routes.py @@ -14,6 +14,7 @@ parse_multidict_query, ) from policyengine_api.response_factory import _make_error_response +from policyengine_api.services.economic_impact_request import EconomicImpactRequest from policyengine_api.services.economy_service import ( BudgetWindowEconomicImpactResult, EconomicImpactResult, @@ -91,15 +92,18 @@ def get_economic_impact( query = parse_multidict_query(AnnualEconomyQuery, request.args) economic_impact_result: EconomicImpactResult = ( economy_service.get_economic_impact( - country_id=country_id, - policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=query.region, - dataset="default", + impact_request=EconomicImpactRequest( + country_id=country_id, + policy_id=policy_id, + baseline_policy_id=baseline_policy_id, + region=query.region, + dataset="default", + options=query.calculation_options(), + api_version=query.version + or COUNTRY_PACKAGE_VERSIONS.get(country_id), + target=query.target, + ), time_period=query.time_period, - options=query.calculation_options(), - api_version=query.version or COUNTRY_PACKAGE_VERSIONS.get(country_id), - target=query.target, ) ) except ValueError as error: @@ -153,16 +157,19 @@ def get_budget_window_economic_impact( query = parse_multidict_query(BudgetWindowEconomyQuery, request.args) economic_impact_result: BudgetWindowEconomicImpactResult = ( economy_service.get_budget_window_economic_impact( - country_id=country_id, - policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=query.region, - dataset="default", + impact_request=EconomicImpactRequest( + country_id=country_id, + policy_id=policy_id, + baseline_policy_id=baseline_policy_id, + region=query.region, + dataset="default", + options=query.calculation_options(), + api_version=query.version + or COUNTRY_PACKAGE_VERSIONS.get(country_id), + target=query.target, + ), start_year=query.start_year, window_size=query.window_size, - options=query.calculation_options(), - api_version=query.version or COUNTRY_PACKAGE_VERSIONS.get(country_id), - target=query.target, ) ) except ValueError as error: diff --git a/policyengine_api/services/economic_impact_request.py b/policyengine_api/services/economic_impact_request.py new file mode 100644 index 000000000..506311302 --- /dev/null +++ b/policyengine_api/services/economic_impact_request.py @@ -0,0 +1,23 @@ +"""The request the economy routes pass to the economy service.""" + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class EconomicImpactRequest: + """One economic impact request: the policies, place, dataset and options. + + ``policy_id`` is the reform and ``baseline_policy_id`` the policy it is + compared with. ``api_version`` is the country model version the caller asked + for, which selects the cache generation; None means the installed version. + """ + + country_id: str + policy_id: int + baseline_policy_id: int + region: str + dataset: str + options: dict + api_version: str | None + target: Literal["general", "cliff"] = "general" diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 020fbb036..e0edab60a 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -5,6 +5,7 @@ import hashlib import json import uuid +from dataclasses import dataclass, replace from enum import Enum from typing import Any, Literal @@ -65,6 +66,7 @@ BudgetWindowCache, BudgetWindowCacheState, ) +from policyengine_api.services.economic_impact_request import EconomicImpactRequest from policyengine_api.services.policy_service import PolicyService from policyengine_api.services.reform_impacts_service import ( ReformImpactExecution, @@ -105,6 +107,32 @@ class ImpactStatus(Enum): BUDGET_WINDOW_SUBMISSION_VALIDATION_ERROR_STATUS_CODES = {400, 422} BUDGET_WINDOW_CLAIM_ATTEMPTS = 3 REFORM_IMPACT_START_CLAIM_ATTEMPTS = 3 +# A budget-window progress message names this many computing years and counts +# the rest. +BUDGET_WINDOW_LISTED_COMPUTING_YEARS = 2 + + +@dataclass(frozen=True) +class BudgetWindowYearProgress: + """Which years of a budget window are completed, computing and still queued.""" + + completed_years: list[str] + computing_years: list[str] + queued_years: list[str] + + +@dataclass(frozen=True) +class RuntimeBundleIdentity: + """The dataset and package versions that an options hash identifies. + + ``runtime_app_name`` names the resolved simulation worker once it is known. + """ + + dataset: str + model_version: str | None + policyengine_version: str | None = None + data_version: str | None = None + runtime_app_name: str | None = None class SimulationOptions(BaseModel): @@ -235,9 +263,7 @@ def computing( cls, *, progress: int, - completed_years: list[str], - computing_years: list[str], - queued_years: list[str], + years: BudgetWindowYearProgress, message: str, cache_status: str | None = None, ) -> "BudgetWindowEconomicImpactResult": @@ -246,9 +272,9 @@ def computing( status=ImpactStatus.COMPUTING, data=None, progress=progress, - completed_years=completed_years, - computing_years=computing_years, - queued_years=queued_years, + completed_years=years.completed_years, + computing_years=years.computing_years, + queued_years=years.queued_years, message=message, cache_status=cache_status, ) @@ -352,15 +378,8 @@ def _parse_json_object(value: JSONValue) -> dict[str, Any]: @observability_runtime.span(ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_REQUEST)) def get_economic_impact( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, + impact_request: EconomicImpactRequest, time_period: str, - options: dict, - api_version: str, - target: Literal["general", "cliff"] = "general", ) -> EconomicImpactResult: """Calculate the society-wide economic impact of a policy reform. @@ -369,28 +388,20 @@ def get_economic_impact( Unexpected failures are logged and re-raised. """ set_runtime_context( - country_id=country_id, - policy_id=policy_id, - baseline_policy_id=baseline_policy_id, + country_id=impact_request.country_id, + policy_id=impact_request.policy_id, + baseline_policy_id=impact_request.baseline_policy_id, simulation_year=time_period, ) economic_impact_setup_options: EconomicImpactSetupOptions | None = None try: # Normalize region early for US; this allows us to accommodate legacy # regions that don't contain a region prefix. - if country_id == "us": - region = normalize_us_region(region) + impact_request = self._with_normalized_region(impact_request) economic_impact_setup_options = self._build_economic_impact_setup_options( - country_id=country_id, - policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, + impact_request, time_period=time_period, - options=options, - api_version=api_version, - target=target, ) return self._get_or_create_economic_impact( @@ -423,16 +434,10 @@ def get_economic_impact( ) def get_budget_window_economic_impact( self, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, + impact_request: EconomicImpactRequest, + *, start_year: str, window_size: int, - options: dict, - api_version: str, - target: Literal["general", "cliff"] = "general", max_active_years: int = BUDGET_WINDOW_MAX_ACTIVE_YEARS, ) -> BudgetWindowEconomicImpactResult: """Return the multi-year budget-window impact, submitting a batch job if needed. @@ -444,33 +449,23 @@ def get_budget_window_economic_impact( the SPM selection or a stored result cannot be certified. """ set_runtime_context( - country_id=country_id, - policy_id=policy_id, - baseline_policy_id=baseline_policy_id, + country_id=impact_request.country_id, + policy_id=impact_request.policy_id, + baseline_policy_id=impact_request.baseline_policy_id, start_year=start_year, window_size=window_size, ) try: - if country_id == "us": - region = normalize_us_region(region) + impact_request = self._with_normalized_region(impact_request) budget_window_setup = budget_window_utils.build_budget_window_request_setup( start_year=start_year, window_size=window_size, - target=target, + target=impact_request.target, ) - start_year = budget_window_setup.start_year - years = budget_window_setup.years setup_options = self._build_economic_impact_setup_options( - country_id=country_id, - policy_id=policy_id, - baseline_policy_id=baseline_policy_id, - region=region, - dataset=dataset, + replace(impact_request, options=dict(impact_request.options)), time_period=budget_window_setup.time_period, - options=dict(options), - api_version=api_version, - target=target, ) cache_key = self._build_budget_window_cache_key(setup_options) @@ -480,129 +475,240 @@ def get_budget_window_economic_impact( cached_state.observability_id, setup_options=setup_options, ) + cached_result = self._budget_window_result_from_state( + cached_state, + setup_options=setup_options, + cache_key=cache_key, + years=budget_window_setup.years, + ) + if cached_result is not None: + return cached_result - if cached_state is not None and cached_state.status == "failed": - return self._replay_budget_window_failure(cached_state) + return self._submit_budget_window( + setup_options, + cache_key, + budget_window_setup, + max_active_years=max_active_years, + ) + except Exception as e: + logger.log_struct( + { + "message": "Error getting budget-window economic impact", + "error_type": type(e).__name__, + }, + severity="ERROR", + ) + raise - if cached_state is not None and cached_state.status == "completed": - cached_result = cached_state.result or {} - try: - validate_worker_result( - cached_result, - setup_options.options.get("spm"), - expected_years=years, - ) - except SPMValidationError as error: - # A stored payload this build cannot certify is terminal, not - # transient: the selection is part of this cache key, so a - # recomputation would submit the identical job and store the - # identical receipts. Record it the way the batch poll does - # and the read above replays it, instead of re-deriving the - # same failure from the same payload on every later poll. - self._budget_window_cache.set_terminal_error( - cache_key, - error.to_dict(), - setup_options.observability_id, - ) - raise - return BudgetWindowEconomicImpactResult.completed( - cached_result, - cache_status="result-hit", - ) + @staticmethod + def _with_normalized_region( + impact_request: EconomicImpactRequest, + ) -> EconomicImpactRequest: + """Give a US request its prefixed region, so legacy bare regions still work.""" + if impact_request.country_id != "us": + return impact_request + return replace( + impact_request, region=normalize_us_region(impact_request.region) + ) - if cached_state is not None and cached_state.status == "submitted": - return self._get_budget_window_result_from_batch_job_id( - batch_job_id=cached_state.batch_job_id or "", - spm=setup_options.options.get("spm"), - cache_key=cache_key, - total_years=len(years), - queued_years_on_submit=years, - cache_status="batch-id-hit", - observability_id=setup_options.observability_id, - ) + def _budget_window_result_from_state( + self, + cached_state: BudgetWindowCacheState, + *, + setup_options: EconomicImpactSetupOptions, + cache_key: str, + years: list[str], + ) -> BudgetWindowEconomicImpactResult | None: + """Answer from this report's stored state, or None if it gives no answer. - if cached_state is not None and cached_state.status == "starting": - return self._build_budget_window_computing_result( - total_years=len(years), + A failure is replayed, a completed result is certified and returned, a + submitted batch is polled, and a starting claim reports every year queued. + """ + if cached_state.status == "failed": + return self._replay_budget_window_failure(cached_state) + + if cached_state.status == "completed": + return self._replay_budget_window_result( + cached_state, + setup_options=setup_options, + cache_key=cache_key, + years=years, + ) + + if cached_state.status == "submitted": + return self._poll_submitted_budget_window( + cached_state, + setup_options=setup_options, + cache_key=cache_key, + years=years, + ) + + if cached_state.status == "starting": + return self._build_budget_window_computing_result( + total_years=len(years), + years=BudgetWindowYearProgress( completed_years=[], computing_years=[], queued_years=years, - progress=0, - cache_status="starting-claim-hit", - ) + ), + progress=0, + cache_status="starting-claim-hit", + ) + return None - claim_token = setup_options.submission_claim_id - cache_status = "starting-claim-hit" - observability_id_candidate = resolve_observability_id( - incoming_observability_id() + def _replay_budget_window_result( + self, + cached_state: BudgetWindowCacheState, + *, + setup_options: EconomicImpactSetupOptions, + cache_key: str, + years: list[str], + ) -> BudgetWindowEconomicImpactResult: + """Return a stored budget-window result after certifying its SPM receipts.""" + cached_result = cached_state.result or {} + try: + validate_worker_result( + cached_result, + setup_options.options.get("spm"), + expected_years=years, ) - owns_claim = self._claim_budget_window_start( + except SPMValidationError as error: + # A stored payload this build cannot certify is terminal, not + # transient: the selection is part of this cache key, so a + # recomputation would submit the identical job and store the + # identical receipts. Record it the way the batch poll does + # and the read above replays it, instead of re-deriving the + # same failure from the same payload on every later poll. + self._budget_window_cache.set_terminal_error( cache_key, - claim_token, - observability_id_candidate, - setup_options=setup_options, + error.to_dict(), + setup_options.observability_id, ) + raise + return BudgetWindowEconomicImpactResult.completed( + cached_result, + cache_status="result-hit", + ) - if owns_claim: - cache_status = "miss" - try: - batch_execution = self._start_budget_window_batch( - setup_options=setup_options, - start_year=start_year, - window_size=window_size, - max_parallel=max_active_years, - ) - self._budget_window_cache.store_submitted( - cache_key, - batch_execution.batch_job_id, - setup_options.observability_id, - ) - except httpx.HTTPStatusError as error: - if ( - error.response.status_code - in BUDGET_WINDOW_SUBMISSION_VALIDATION_ERROR_STATUS_CODES - ): - failed_result = BudgetWindowEconomicImpactResult.failed( - self._build_budget_window_submission_error_message(error), - queued_years=years, - cache_status=cache_status, - ) - self._budget_window_cache.set_execution_failure( - cache_key, - failed_result.model_dump(mode="json"), - setup_options.observability_id, - ) - return failed_result - self._budget_window_cache.clear_starting_claim( - cache_key, - claim_token, - setup_options.observability_id, - ) - raise - except Exception: - self._budget_window_cache.clear_starting_claim( - cache_key, - claim_token, - setup_options.observability_id, - ) - raise - return self._build_budget_window_computing_result( - total_years=len(years), + def _poll_submitted_budget_window( + self, + cached_state: BudgetWindowCacheState, + *, + setup_options: EconomicImpactSetupOptions, + cache_key: str, + years: list[str], + ) -> BudgetWindowEconomicImpactResult: + """Poll the batch job this report's stored state says was submitted.""" + return self._get_budget_window_result_from_batch_job_id( + batch_job_id=cached_state.batch_job_id or "", + cache_key=cache_key, + setup_options=setup_options, + queued_years_on_submit=years, + cache_status="batch-id-hit", + ) + + def _submit_budget_window( + self, + setup_options: EconomicImpactSetupOptions, + cache_key: str, + budget_window_setup: budget_window_utils.BudgetWindowRequestSetup, + *, + max_active_years: int, + ) -> BudgetWindowEconomicImpactResult: + """Submit the batch job if this request wins the claim, and report it queued. + + A request that loses the claim reports the window queued without + submitting; a submission the gateway rejects as invalid is a failed result. + """ + years = budget_window_setup.years + cache_status = "starting-claim-hit" + observability_id_candidate = resolve_observability_id( + incoming_observability_id() + ) + owns_claim = self._claim_budget_window_start( + cache_key, + setup_options.submission_claim_id, + observability_id_candidate, + setup_options=setup_options, + ) + + if owns_claim: + cache_status = "miss" + rejected_result = self._submit_claimed_budget_window_batch( + setup_options, + cache_key, + budget_window_setup, + max_active_years=max_active_years, + ) + if rejected_result is not None: + return rejected_result + return self._build_budget_window_computing_result( + total_years=len(years), + years=BudgetWindowYearProgress( completed_years=[], computing_years=[], queued_years=years, - progress=0, - cache_status=cache_status, + ), + progress=0, + cache_status=cache_status, + ) + + def _submit_claimed_budget_window_batch( + self, + setup_options: EconomicImpactSetupOptions, + cache_key: str, + budget_window_setup: budget_window_utils.BudgetWindowRequestSetup, + *, + max_active_years: int, + ) -> BudgetWindowEconomicImpactResult | None: + """Submit the batch this request claimed and record its job ID. + + Return a failed result when the gateway rejects the submission as invalid, + otherwise None. On any other error release the claim and re-raise. + """ + claim_token = setup_options.submission_claim_id + try: + batch_execution = self._start_budget_window_batch( + setup_options=setup_options, + start_year=budget_window_setup.start_year, + window_size=budget_window_setup.window_size, + max_parallel=max_active_years, ) - except Exception as e: - logger.log_struct( - { - "message": "Error getting budget-window economic impact", - "error_type": type(e).__name__, - }, - severity="ERROR", + self._budget_window_cache.store_submitted( + cache_key, + batch_execution.batch_job_id, + setup_options.observability_id, + ) + except httpx.HTTPStatusError as error: + if ( + error.response.status_code + in BUDGET_WINDOW_SUBMISSION_VALIDATION_ERROR_STATUS_CODES + ): + failed_result = BudgetWindowEconomicImpactResult.failed( + self._build_budget_window_submission_error_message(error), + queued_years=budget_window_setup.years, + cache_status="miss", + ) + self._budget_window_cache.set_execution_failure( + cache_key, + failed_result.model_dump(mode="json"), + setup_options.observability_id, + ) + return failed_result + self._budget_window_cache.clear_starting_claim( + cache_key, + claim_token, + setup_options.observability_id, ) raise + except Exception: + self._budget_window_cache.clear_starting_claim( + cache_key, + claim_token, + setup_options.observability_id, + ) + raise + return None @staticmethod def _replay_budget_window_failure( @@ -693,16 +799,11 @@ def _build_budget_window_batch_payload( setup_options.reform_policy_id, ) sim_config: SimulationOptions = self._setup_sim_options( - country_id=setup_options.country_id, - spm=setup_options.options.get("spm"), + setup_options, reform_policy=reform_policy, baseline_policy=baseline_policy, - region=setup_options.region, time_period=start_year, - scope="macro", include_cliffs=False, - model_version=setup_options.model_version, - policyengine_version=setup_options.policyengine_version, ) sim_params = sim_config.model_dump() sim_params.pop("time_period", None) @@ -771,13 +872,13 @@ def _get_budget_window_result_from_batch_job_id( *, batch_job_id: str, cache_key: str, - total_years: int, + setup_options: EconomicImpactSetupOptions, queued_years_on_submit: list[str], - spm: dict | None = None, cache_status: str | None = None, - observability_id: str | None, ) -> BudgetWindowEconomicImpactResult: - resolved_observability_id = observability_id + total_years = len(queued_years_on_submit) + spm = setup_options.options.get("spm") + resolved_observability_id = setup_options.observability_id try: batch_execution = self._simulation_gateway.get_budget_window_batch_by_id( batch_job_id @@ -841,9 +942,11 @@ def _get_budget_window_result_from_batch_job_id( if batch_execution.status in EXECUTION_STATUSES_PENDING: return self._build_budget_window_computing_result( total_years=total_years, - completed_years=batch_execution.completed_years, - computing_years=batch_execution.running_years, - queued_years=batch_execution.queued_years, + years=BudgetWindowYearProgress( + completed_years=batch_execution.completed_years, + computing_years=batch_execution.running_years, + queued_years=batch_execution.queued_years, + ), progress=batch_execution.progress, cache_status=cache_status, ) @@ -857,44 +960,35 @@ def _build_budget_window_computing_result( self, *, total_years: int, - completed_years: list[str], - computing_years: list[str], - queued_years: list[str], + years: BudgetWindowYearProgress, progress: int | None = None, cache_status: str | None = None, ) -> BudgetWindowEconomicImpactResult: resolved_progress = progress if resolved_progress is None: - resolved_progress = round((len(completed_years) / total_years) * 100) + resolved_progress = round((len(years.completed_years) / total_years) * 100) return BudgetWindowEconomicImpactResult.computing( progress=resolved_progress, - completed_years=completed_years, - computing_years=computing_years, - queued_years=queued_years, + years=years, message=self._build_budget_window_progress_message( - completed_years=completed_years, + completed_years=years.completed_years, total_years=total_years, - computing_years=computing_years, - queued_years=queued_years, + computing_years=years.computing_years, + queued_years=years.queued_years, ), cache_status=cache_status, ) def _build_economic_impact_setup_options( self, + impact_request: EconomicImpactRequest, *, - country_id: str, - policy_id: int, - baseline_policy_id: int, - region: str, - dataset: str, time_period: str, - options: dict, - api_version: str, - target: Literal["general", "cliff"] = "general", ) -> EconomicImpactSetupOptions: - if dataset != "default": + country_id = impact_request.country_id + options = impact_request.options + if impact_request.dataset != "default": msg = "Custom datasets are not supported for economy calculations" raise ValueError(msg) resolved_spm = validate_worker_spm( @@ -907,7 +1001,9 @@ def _build_economic_impact_setup_options( if resolved_spm is not None: options = {**options, "spm": resolved_spm} submission_claim_id = self._create_submission_claim_id() - cache_version = get_economy_impact_cache_version(country_id, api_version) + cache_version = get_economy_impact_cache_version( + country_id, impact_request.api_version + ) country_package_version = COUNTRY_PACKAGE_VERSIONS.get(country_id) resolved_dataset = "default" resolved_data_version = None @@ -916,10 +1012,12 @@ def _build_economic_impact_setup_options( ) options_hash = self._build_options_hash( options=options, - model_version=country_package_version, - dataset=resolved_dataset, - data_version=resolved_data_version, - policyengine_version=policyengine_version, + bundle=RuntimeBundleIdentity( + dataset=resolved_dataset, + model_version=country_package_version, + policyengine_version=policyengine_version, + data_version=resolved_data_version, + ), ) return EconomicImpactSetupOptions.model_validate( @@ -927,14 +1025,14 @@ def _build_economic_impact_setup_options( "submission_claim_id": submission_claim_id, "observability_id": None, "country_id": country_id, - "reform_policy_id": policy_id, - "baseline_policy_id": baseline_policy_id, - "region": region, + "reform_policy_id": impact_request.policy_id, + "baseline_policy_id": impact_request.baseline_policy_id, + "region": impact_request.region, "dataset": resolved_dataset, "time_period": time_period, "options": options, "api_version": cache_version, - "target": target, + "target": impact_request.target, "model_version": country_package_version, "policyengine_version": policyengine_version, "data_version": resolved_data_version, @@ -960,6 +1058,34 @@ def _get_or_create_economic_impact( severity="INFO", ) + most_recent_impact = self._get_reusable_impact(setup_options) + impact_action: ImpactAction = self._determine_impact_action( + most_recent_impact=most_recent_impact + ) + if most_recent_impact is not None: + setup_options.observability_id = restore_observability_id( + getattr(most_recent_impact, "observability_id", None) + ) + if impact_action == ImpactAction.CREATE: + return self._create_economic_impact(setup_options, impact_action) + + observability_runtime.event( + "economy.cache_decision", + attributes={"cache_event": impact_action.value}, + ) + return self._answer_from_stored_impact( + setup_options, impact_action, most_recent_impact + ) + + def _get_reusable_impact( + self, setup_options: EconomicImpactSetupOptions + ) -> CachedReformImpact | None: + """Return the newest stored impact these options can reuse, if there is one. + + A finished impact computed by a different worker application than this + request now resolves to is refetched under the refreshed options hash, and + dropped unless one matches it exactly. + """ most_recent_impact: CachedReformImpact | None = self._get_most_recent_impact( setup_options=setup_options ) @@ -973,20 +1099,15 @@ def _get_or_create_economic_impact( or most_recent_impact.options_hash != setup_options.options_hash ): most_recent_impact = None + return most_recent_impact - impact_action: ImpactAction = self._determine_impact_action( - most_recent_impact=most_recent_impact - ) - if most_recent_impact is not None: - setup_options.observability_id = restore_observability_id( - getattr(most_recent_impact, "observability_id", None) - ) - if impact_action != ImpactAction.CREATE: - observability_runtime.event( - "economy.cache_decision", - attributes={"cache_event": impact_action.value}, - ) - + def _answer_from_stored_impact( + self, + setup_options: EconomicImpactSetupOptions, + impact_action: ImpactAction, + most_recent_impact: CachedReformImpact | None, + ) -> EconomicImpactResult: + """Return a stored impact's result, its failure, or its execution's progress.""" if impact_action == ImpactAction.COMPLETED: logger.log_struct( { @@ -1021,80 +1142,96 @@ def _get_or_create_economic_impact( most_recent_impact=most_recent_impact, ) - if impact_action == ImpactAction.CREATE: - existing_claim = None - with observability_runtime.span( - ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_RESOLVE_RUNTIME_BUNDLE) - ): - self._resolve_runtime_bundle_for_setup_options(setup_options) - observability_id_candidate = resolve_observability_id( - incoming_observability_id() - ) - for _attempt in range(REFORM_IMPACT_START_CLAIM_ATTEMPTS): - if self._claim_reform_impact_start( - setup_options, - observability_id_candidate, - ): - setup_options.observability_id = start_observability_id( - observability_id_candidate - ) - break - existing_claim = self._get_reform_impact_start_claim(setup_options) - if existing_claim is None: - continue - setup_options.observability_id = restore_observability_id( - existing_claim.observability_id - ) - break - else: - msg = "reform-impact submission ownership changed repeatedly" - raise CacheCoordinationError(msg) - - # The identifier above was selected while the runtime-resolution - # stage was current. Reapply it now so the containing cache-decision - # span carries the same report identifier. - if setup_options.observability_id is not None: - set_runtime_context(observability_id=setup_options.observability_id) - observability_runtime.event( - "economy.cache_decision", - attributes={"cache_event": impact_action.value}, - ) - if existing_claim is not None: - logger.log_struct( - { - "message": "Another request owns this reform-impact submission", - **setup_options.model_dump(), - }, - severity="INFO", - ) - return EconomicImpactResult.computing() + msg = f"Unexpected impact action: {impact_action}" + raise ValueError(msg) + + def _create_economic_impact( + self, + setup_options: EconomicImpactSetupOptions, + impact_action: ImpactAction, + ) -> EconomicImpactResult: + """Submit a new simulation unless another request already owns submitting it.""" + existing_claim = self._claim_reform_impact_submission(setup_options) + + # The identifier above was selected while the runtime-resolution + # stage was current. Reapply it now so the containing cache-decision + # span carries the same report identifier. + if setup_options.observability_id is not None: + set_runtime_context(observability_id=setup_options.observability_id) + observability_runtime.event( + "economy.cache_decision", + attributes={"cache_event": impact_action.value}, + ) + if existing_claim is not None: logger.log_struct( { - "message": ( - "No previous economic impact record found in db; creating new " - "simulation run" - ), + "message": "Another request owns this reform-impact submission", **setup_options.model_dump(), }, severity="INFO", ) - try: - result = self._handle_create_impact( - setup_options=setup_options, - ) - except ReformImpactHandoffError: - # The upstream job exists, but its polling pointer was not - # durably handed off. Retain the claim until expiry so another - # request cannot immediately submit a duplicate job. - raise - except Exception: - self._release_reform_impact_start(setup_options) - raise + return EconomicImpactResult.computing() + logger.log_struct( + { + "message": ( + "No previous economic impact record found in db; creating new " + "simulation run" + ), + **setup_options.model_dump(), + }, + severity="INFO", + ) + try: + result = self._handle_create_impact( + setup_options=setup_options, + ) + except ReformImpactHandoffError: + # The upstream job exists, but its polling pointer was not + # durably handed off. Retain the claim until expiry so another + # request cannot immediately submit a duplicate job. + raise + except Exception: self._release_reform_impact_start(setup_options) - return result + raise + self._release_reform_impact_start(setup_options) + return result - msg = f"Unexpected impact action: {impact_action}" - raise ValueError(msg) + def _claim_reform_impact_submission( + self, setup_options: EconomicImpactSetupOptions + ) -> ReformImpactStartClaim | None: + """Resolve the runtime bundle, then claim the right to submit the simulation. + + Return None when this request now owns the submission, or the claim of the + request that does. Raise CacheCoordinationError if ownership keeps changing. + """ + existing_claim = None + with observability_runtime.span( + ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_RESOLVE_RUNTIME_BUNDLE) + ): + self._resolve_runtime_bundle_for_setup_options(setup_options) + observability_id_candidate = resolve_observability_id( + incoming_observability_id() + ) + for _attempt in range(REFORM_IMPACT_START_CLAIM_ATTEMPTS): + if self._claim_reform_impact_start( + setup_options, + observability_id_candidate, + ): + setup_options.observability_id = start_observability_id( + observability_id_candidate + ) + break + existing_claim = self._get_reform_impact_start_claim(setup_options) + if existing_claim is None: + continue + setup_options.observability_id = restore_observability_id( + existing_claim.observability_id + ) + break + else: + msg = "reform-impact submission ownership changed repeatedly" + raise CacheCoordinationError(msg) + return existing_claim def _resolve_runtime_bundle_for_setup_options( self, @@ -1112,10 +1249,19 @@ def _resolve_runtime_bundle_for_setup_options( setup_options.options_hash = self._build_options_hash( options=setup_options.options, - model_version=setup_options.model_version, + bundle=self._runtime_bundle(setup_options), + ) + + @staticmethod + def _runtime_bundle( + setup_options: EconomicImpactSetupOptions, + ) -> RuntimeBundleIdentity: + """Return the dataset and package versions these setup options resolved.""" + return RuntimeBundleIdentity( dataset=setup_options.dataset, - data_version=setup_options.data_version, + model_version=setup_options.model_version, policyengine_version=setup_options.policyengine_version, + data_version=setup_options.data_version, runtime_app_name=setup_options.runtime_app_name, ) @@ -1209,9 +1355,14 @@ def _build_budget_window_progress_message( ) -> str: completed_count = len(completed_years) if computing_years: - active_years = ", ".join(computing_years[:2]) - if len(computing_years) > 2: - active_years = f"{active_years} + {len(computing_years) - 2} more" + active_years = ", ".join( + computing_years[:BUDGET_WINDOW_LISTED_COMPUTING_YEARS] + ) + if len(computing_years) > BUDGET_WINDOW_LISTED_COMPUTING_YEARS: + unlisted_count = ( + len(computing_years) - BUDGET_WINDOW_LISTED_COMPUTING_YEARS + ) + active_years = f"{active_years} + {unlisted_count} more" return ( f"Scoring {active_years} " f"({completed_count} of {total_years} complete)..." @@ -1476,16 +1627,11 @@ def _handle_create_impact( ) sim_config: SimulationOptions = self._setup_sim_options( - country_id=setup_options.country_id, - spm=setup_options.options.get("spm"), + setup_options, reform_policy=reform_policy, baseline_policy=baseline_policy, - region=setup_options.region, time_period=setup_options.time_period, - scope="macro", include_cliffs=setup_options.target == "cliff", - model_version=setup_options.model_version, - policyengine_version=setup_options.policyengine_version, ) sim_params = sim_config.model_dump(mode="json") @@ -1555,56 +1701,49 @@ def _handle_create_impact( def _setup_sim_options( self, - country_id: str, + setup_options: EconomicImpactSetupOptions, + *, reform_policy: dict[str, Any] | str, baseline_policy: dict[str, Any] | str, - region: str, - time_period: str, - scope: Literal["macro", "household"] = "macro", - *, + time_period: str | int, include_cliffs: bool = False, - model_version: str | None = None, - policyengine_version: str | None = None, - spm: dict | None = None, ) -> SimulationOptions: - """Set up the simulation options for the simulation API job.""" + """Set up the macro simulation options for one simulation API job.""" return SimulationOptions.model_validate( { - "country": country_id, - "spm": spm, - "scope": scope, + "country": setup_options.country_id, + "spm": setup_options.options.get("spm"), + "scope": "macro", "reform": self._parse_json_object(reform_policy), "baseline": self._parse_json_object(baseline_policy), "time_period": time_period, "include_cliffs": include_cliffs, - "region": self._setup_region(country_id=country_id, region=region), - "model_version": model_version, - "policyengine_version": policyengine_version, + "region": self._setup_region( + country_id=setup_options.country_id, region=setup_options.region + ), + "model_version": setup_options.model_version, + "policyengine_version": setup_options.policyengine_version, } ) def _build_options_hash( self, options: dict, - model_version: str | None, - dataset: str, - runtime_app_name: str | None = None, - data_version: str | None = None, - policyengine_version: str | None = None, + bundle: RuntimeBundleIdentity, ) -> str: option_pairs = "&".join(f"{key}={options[key]}" for key in sorted(options)) bundle_parts = [ - f"dataset={dataset}", - f"model_version={model_version}", + f"dataset={bundle.dataset}", + f"model_version={bundle.model_version}", ] - if data_version: - bundle_parts.append(f"data_version={data_version}") - if policyengine_version: - bundle_parts.append(f"policyengine_version={policyengine_version}") + if bundle.data_version: + bundle_parts.append(f"data_version={bundle.data_version}") + if bundle.policyengine_version: + bundle_parts.append(f"policyengine_version={bundle.policyengine_version}") if options.get("spm") is not None: bundle_parts.append("spm_provenance_schema=canonical-spm-comparison-v2") - if runtime_app_name: - bundle_parts.append(f"runtime_app_name={runtime_app_name}") + if bundle.runtime_app_name: + bundle_parts.append(f"runtime_app_name={bundle.runtime_app_name}") return "[" + "&".join([option_pairs, *bundle_parts]).strip("&") + "]" def _build_options_hash_lookup_pattern(self, options_hash: str) -> str: @@ -1646,11 +1785,7 @@ def _should_refresh_cached_impact( setup_options.model_version = resolved_model_version setup_options.options_hash = self._build_options_hash( options=setup_options.options, - model_version=resolved_model_version, - dataset=setup_options.dataset, - data_version=setup_options.data_version, - policyengine_version=setup_options.policyengine_version, - runtime_app_name=runtime_app_name, + bundle=self._runtime_bundle(setup_options), ) if ( not isinstance(cached_resolved_app_name, str) diff --git a/tests/to_refactor/python/test_economy_budget_window_routes.py b/tests/to_refactor/python/test_economy_budget_window_routes.py index 29b6a4028..e52a715c8 100644 --- a/tests/to_refactor/python/test_economy_budget_window_routes.py +++ b/tests/to_refactor/python/test_economy_budget_window_routes.py @@ -149,6 +149,10 @@ def test_budget_window_route_rejects_end_year_after_2099(api_client): def test_budget_window_route_passes_version_to_service( mock_get_budget_window_economic_impact, api_client ): + from policyengine_api.services.economic_impact_request import ( + EconomicImpactRequest, + ) + mock_result = _mock_budget_window_result() mock_get_budget_window_economic_impact.return_value = mock_result @@ -162,16 +166,18 @@ def test_budget_window_route_passes_version_to_service( assert response.status_code == 200 assert data["status"] == "ok" mock_get_budget_window_economic_impact.assert_called_once_with( - country_id="us", - policy_id=123, - baseline_policy_id=456, - region="us", - dataset="default", + impact_request=EconomicImpactRequest( + country_id="us", + policy_id=123, + baseline_policy_id=456, + region="us", + dataset="default", + options={}, + api_version="1.2.3", + target="general", + ), start_year="2026", window_size=2, - options={}, - api_version="1.2.3", - target="general", ) diff --git a/tests/unit/routes/test_economy_failure_replay.py b/tests/unit/routes/test_economy_failure_replay.py index 5532aab04..6d882acda 100644 --- a/tests/unit/routes/test_economy_failure_replay.py +++ b/tests/unit/routes/test_economy_failure_replay.py @@ -12,6 +12,7 @@ from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.reform_impacts import ReformImpactCache from policyengine_api.services import economy_service as economy_module +from policyengine_api.services.economic_impact_request import EconomicImpactRequest from policyengine_api.services.economy_service import EconomyService from policyengine_api.services.reform_impacts_service import ( ReformImpactExecution, @@ -60,14 +61,16 @@ def resolve_app_name( simulation_entrypoint_=gateway, ) setup = service._build_economic_impact_setup_options( - country_id="us", - policy_id=1, - baseline_policy_id=2, - region="us", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + options={}, + api_version="1.0.0", + ), time_period="2026", - options={}, - api_version="1.0.0", ) service._resolve_runtime_bundle_for_setup_options(setup) impacts.set_reform_impact( diff --git a/tests/unit/routes/test_economy_spm_query.py b/tests/unit/routes/test_economy_spm_query.py index b2abba047..1667e4969 100644 --- a/tests/unit/routes/test_economy_spm_query.py +++ b/tests/unit/routes/test_economy_spm_query.py @@ -84,7 +84,7 @@ def test_json_spm_selection_reaches_real_worker_validation(economy_http): ) assert response.status_code == 200, response.get_json() assert response.get_json()["status"] == "computing" - assert dispatch.call_args.kwargs["options"] == { + assert dispatch.call_args.kwargs["impact_request"].options == { "spm": {"geography_kind": "national"} } assert setups[0].options["spm"]["geography_kind"] == "national" @@ -171,9 +171,10 @@ def test_omitted_selection_and_query_defaults_are_preserved(economy_http): client, path, query, dispatch, _, setups = economy_http response = client.get(path, query_string=query) assert response.status_code == 200 - assert dispatch.call_args.kwargs["options"] == {} - assert dispatch.call_args.kwargs["dataset"] == "default" - assert dispatch.call_args.kwargs["target"] == "general" + impact_request = dispatch.call_args.kwargs["impact_request"] + assert impact_request.options == {} + assert impact_request.dataset == "default" + assert impact_request.target == "general" assert setups[0].options["spm"]["geography_kind"] == "county" @@ -225,7 +226,7 @@ def test_bundle_version_bump_without_measurements_still_dispatches( monkeypatch.setattr(spm, "simulation_supports_spm", lambda _: False) response = client.get(path, query_string=query) assert response.status_code == 200, response.get_json() - assert dispatch.call_args.kwargs["options"] == {} + assert dispatch.call_args.kwargs["impact_request"].options == {} gateway.get_spm_capability.assert_not_called() diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index cc4878c5d..39d037fee 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -12,6 +12,7 @@ from policyengine_api.runtime_cache.reform_impacts import ReformImpactCache from policyengine_api.services import economy_service as economy_module from policyengine_api.services.budget_window_cache import BudgetWindowCache +from policyengine_api.services.economic_impact_request import EconomicImpactRequest from policyengine_api.services.economy_service import EconomyService from policyengine_api.services.reform_impacts_service import ( ReformImpactExecution, @@ -153,14 +154,16 @@ def transport(request): with httpx.Client(transport=httpx.MockTransport(transport)) as gateway.client: setup = service._build_economic_impact_setup_options( - country_id="us", - policy_id=1, - baseline_policy_id=2, - region="us", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + options={}, + api_version="1.0.0", + ), time_period="budget_window:2035:2" if budget_window else "2036", - options={}, - api_version="1.0.0", ) if budget_window: cache_key = service._build_budget_window_cache_key(setup) @@ -305,14 +308,16 @@ def transport(request): # this build cannot certify, exactly as a worker defect would leave it. with httpx.Client(transport=httpx.MockTransport(transport)) as gateway.client: setup = service._build_economic_impact_setup_options( - country_id="us", - policy_id=1, - baseline_policy_id=2, - region="us", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + options={}, + api_version="1.0.0", + ), time_period="budget_window:2035:2" if budget_window else "2036", - options={}, - api_version="1.0.0", ) if budget_window: cache_key = service._build_budget_window_cache_key(setup) diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index caf988bc8..953759821 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -1,4 +1,5 @@ import json +from dataclasses import replace from typing import Literal from unittest.mock import MagicMock, call, patch @@ -16,15 +17,18 @@ ReformImpactStartClaim, ) from policyengine_api.services.budget_window_cache import BudgetWindowCacheState +from policyengine_api.services.economic_impact_request import EconomicImpactRequest from policyengine_api.services.economy_service import ( BUDGET_WINDOW_MAX_END_YEAR, BUDGET_WINDOW_MAX_YEARS, REFORM_IMPACT_START_CLAIM_ATTEMPTS, + BudgetWindowYearProgress, EconomicImpactResult, EconomicImpactSetupOptions, EconomyService, ImpactAction, ImpactStatus, + RuntimeBundleIdentity, ) from policyengine_api.services.policy_service import PolicyService from policyengine_api.services.reform_impacts_service import ( @@ -63,6 +67,22 @@ pytest_plugins = ("tests.fixtures.services.economy_service",) +def setup_options_for(country_id: str, region: str) -> EconomicImpactSetupOptions: + """Return resolved setup options for one country and pre-normalized region.""" + return EconomicImpactSetupOptions( + submission_claim_id=MOCK_SUBMISSION_CLAIM_ID, + country_id=country_id, + reform_policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region=region, + dataset=MOCK_DATASET, + time_period=MOCK_TIME_PERIOD, + options={}, + api_version=MOCK_API_VERSION, + target="general", + ) + + def hash_prefix_lookup(reform_impacts_service: MagicMock) -> MagicMock: """Return the mocked lookup of reform impacts by options-hash prefix.""" lookup: MagicMock = ( @@ -159,15 +179,17 @@ def economy_service(self): @pytest.fixture def base_params(self): return { - "country_id": MOCK_COUNTRY_ID, - "policy_id": MOCK_POLICY_ID, - "baseline_policy_id": MOCK_BASELINE_POLICY_ID, - "region": MOCK_REGION, - "dataset": MOCK_DATASET, + "impact_request": EconomicImpactRequest( + country_id=MOCK_COUNTRY_ID, + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region=MOCK_REGION, + dataset=MOCK_DATASET, + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target="general", + ), "time_period": MOCK_TIME_PERIOD, - "options": MOCK_OPTIONS, - "api_version": MOCK_API_VERSION, - "target": "general", } @pytest.mark.usefixtures( @@ -776,14 +798,16 @@ def test__given_policies_created_through_orm__submits_decoded_json( ) result = service.get_economic_impact( - country_id="us", - policy_id=reform_policy_id, - baseline_policy_id=baseline_policy_id, - region="us", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=reform_policy_id, + baseline_policy_id=baseline_policy_id, + region="us", + dataset="default", + options={}, + api_version="test", + ), time_period="2026", - options={}, - api_version="test", ) assert result.status is ImpactStatus.COMPUTING @@ -1219,15 +1243,17 @@ def test__given_uk_request__preserves_model_version_in_bundle( hash_prefix_lookup(mock_reform_impacts_service).return_value = [] economy_service.get_economic_impact( - country_id="uk", - policy_id=MOCK_POLICY_ID, - baseline_policy_id=MOCK_BASELINE_POLICY_ID, - region="uk", - dataset="default", + EconomicImpactRequest( + country_id="uk", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="uk", + dataset="default", + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target="general", + ), time_period=MOCK_TIME_PERIOD, - options=MOCK_OPTIONS, - api_version=MOCK_API_VERSION, - target="general", ) sim_params = mock_simulation_entrypoint.run.call_args[0][0] @@ -1248,16 +1274,18 @@ def economy_service( @pytest.fixture def base_params(self): return { - "country_id": MOCK_COUNTRY_ID, - "policy_id": MOCK_POLICY_ID, - "baseline_policy_id": MOCK_BASELINE_POLICY_ID, - "region": MOCK_REGION, - "dataset": MOCK_DATASET, + "impact_request": EconomicImpactRequest( + country_id=MOCK_COUNTRY_ID, + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region=MOCK_REGION, + dataset=MOCK_DATASET, + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target="general", + ), "start_year": "2026", "window_size": 3, - "options": MOCK_OPTIONS, - "api_version": MOCK_API_VERSION, - "target": "general", } def test__given_no_cached_batch__submits_parent_batch_and_returns_queued_result( @@ -1836,7 +1864,9 @@ def test__given_modal_validation_empty_error__falls_back_to_exception_text( def test__given_cliff_target__raises_value_error( self, economy_service, base_params ): - base_params["target"] = "cliff" + base_params["impact_request"] = replace( + base_params["impact_request"], target="cliff" + ) with pytest.raises( ValueError, @@ -1913,7 +1943,10 @@ def test__given_reordered_options__uses_same_budget_window_cache_identity( economy_service.get_budget_window_economic_impact( **{ **base_params, - "options": {"staging_probe": "abc", "analysis_mode": "x"}, + "impact_request": replace( + base_params["impact_request"], + options={"staging_probe": "abc", "analysis_mode": "x"}, + ), } ) first_cache_key_kwargs = dict( @@ -1924,7 +1957,10 @@ def test__given_reordered_options__uses_same_budget_window_cache_identity( economy_service.get_budget_window_economic_impact( **{ **base_params, - "options": {"analysis_mode": "x", "staging_probe": "abc"}, + "impact_request": replace( + base_params["impact_request"], + options={"analysis_mode": "x", "staging_probe": "abc"}, + ), } ) second_cache_key_kwargs = dict( @@ -1943,8 +1979,9 @@ def test__given_legacy_us_region__normalizes_before_building_cache_key( base_params, mock_budget_window_cache, ): - base_params["region"] = "ca" - base_params["dataset"] = "default" + base_params["impact_request"] = replace( + base_params["impact_request"], region="ca", dataset="default" + ) result = economy_service.get_budget_window_economic_impact(**base_params) @@ -1983,9 +2020,11 @@ def test__given_missing_progress__computes_progress_from_completed_years( ): result = economy_service._build_budget_window_computing_result( total_years=4, - completed_years=["2026"], - computing_years=["2027", "2028", "2029"], - queued_years=[], + years=BudgetWindowYearProgress( + completed_years=["2026"], + computing_years=["2027", "2028", "2029"], + queued_years=[], + ), progress=None, ) @@ -1997,9 +2036,11 @@ def test__given_no_active_or_queued_years__uses_generic_progress_message( ): result = economy_service._build_budget_window_computing_result( total_years=3, - completed_years=["2026"], - computing_years=[], - queued_years=[], + years=BudgetWindowYearProgress( + completed_years=["2026"], + computing_years=[], + queued_years=[], + ), progress=None, ) @@ -2537,12 +2578,10 @@ def test__given_us_nationwide__returns_correct_sim_options(self): service = EconomyService() sim_options_model = service._setup_sim_options( - self.test_country_id, - self.test_reform_policy, - self.test_current_law_baseline_policy, - self.test_region, - self.test_time_period, - self.test_scope, + setup_options_for(self.test_country_id, self.test_region), + reform_policy=self.test_reform_policy, + baseline_policy=self.test_current_law_baseline_policy, + time_period=self.test_time_period, ) sim_options = sim_options_model.model_dump() @@ -2568,12 +2607,10 @@ def test__given_us_state_ca__returns_correct_sim_options(self): service = EconomyService() sim_options_model = service._setup_sim_options( - country_id, - reform_policy, - current_law_baseline_policy, - region, - time_period, - scope, + setup_options_for(country_id, region), + reform_policy=reform_policy, + baseline_policy=current_law_baseline_policy, + time_period=time_period, ) sim_options = sim_options_model.model_dump() @@ -2596,12 +2633,10 @@ def test__given_us_state_utah__returns_correct_sim_options(self): service = EconomyService() sim_options_model = service._setup_sim_options( - country_id, - reform_policy, - current_law_baseline_policy, - region, - time_period, - scope, + setup_options_for(country_id, region), + reform_policy=reform_policy, + baseline_policy=current_law_baseline_policy, + time_period=time_period, ) sim_options = sim_options_model.model_dump() @@ -2624,12 +2659,10 @@ def test__given_cliff_target__returns_correct_sim_options(self): service = EconomyService() sim_options_model = service._setup_sim_options( - country_id, - reform_policy, - current_law_baseline_policy, - region, - time_period, - scope, + setup_options_for(country_id, region), + reform_policy=reform_policy, + baseline_policy=current_law_baseline_policy, + time_period=time_period, include_cliffs=True, ) @@ -2649,17 +2682,14 @@ def test__given_uk__returns_correct_sim_options(self): current_law_baseline_policy = json.dumps({}) region = "uk" time_period = 2025 - scope = "macro" service = EconomyService() sim_options_model = service._setup_sim_options( - country_id, - reform_policy, - current_law_baseline_policy, - region, - time_period, - scope, + setup_options_for(country_id, region), + reform_policy=reform_policy, + baseline_policy=current_law_baseline_policy, + time_period=time_period, ) sim_options = sim_options_model.model_dump() @@ -2675,17 +2705,14 @@ def test__given_congressional_district__returns_correct_sim_options( current_law_baseline_policy = json.dumps({}) region = "congressional_district/CA-37" # Pre-normalized time_period = 2025 - scope = "macro" service = EconomyService() sim_options_model = service._setup_sim_options( - country_id, - reform_policy, - current_law_baseline_policy, - region, - time_period, - scope, + setup_options_for(country_id, region), + reform_policy=reform_policy, + baseline_policy=current_law_baseline_policy, + time_period=time_period, ) sim_options = sim_options_model.model_dump() @@ -2793,14 +2820,16 @@ def test__given_custom_dataset__rejects_it(self, dataset): service = EconomyService() with pytest.raises(ValueError, match="Custom datasets are not supported"): service._build_economic_impact_setup_options( - country_id="us", - policy_id=MOCK_POLICY_ID, - baseline_policy_id=MOCK_BASELINE_POLICY_ID, - region="us", - dataset=dataset, + EconomicImpactRequest( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="us", + dataset=dataset, + options={}, + api_version=MOCK_API_VERSION, + ), time_period=MOCK_TIME_PERIOD, - options={}, - api_version=MOCK_API_VERSION, ) class TestValidateUsRegion: @@ -2852,15 +2881,15 @@ class TestBuildOptionsHash: def test__given_reordered_options__returns_same_hash(self): service = EconomyService() + bundle = RuntimeBundleIdentity( + dataset="hf://policyengine/test.h5@1.0", model_version="1.2.3" + ) + first = service._build_options_hash( - options={"staging_probe": "abc", "analysis_mode": "x"}, - model_version="1.2.3", - dataset="hf://policyengine/test.h5@1.0", + options={"staging_probe": "abc", "analysis_mode": "x"}, bundle=bundle ) second = service._build_options_hash( - options={"analysis_mode": "x", "staging_probe": "abc"}, - model_version="1.2.3", - dataset="hf://policyengine/test.h5@1.0", + options={"analysis_mode": "x", "staging_probe": "abc"}, bundle=bundle ) assert first == second diff --git a/tests/unit/services/test_economy_service_request_plumbing.py b/tests/unit/services/test_economy_service_request_plumbing.py index 3db06587f..a1cea4c99 100644 --- a/tests/unit/services/test_economy_service_request_plumbing.py +++ b/tests/unit/services/test_economy_service_request_plumbing.py @@ -20,9 +20,11 @@ reform_impact_id, ) from policyengine_api.services.budget_window_cache import BudgetWindowCache +from policyengine_api.services.economic_impact_request import EconomicImpactRequest from policyengine_api.services.economy_service import ( EconomicImpactResult, EconomyService, + RuntimeBundleIdentity, ) from policyengine_api.services.reform_impacts_service import ReformImpactsService from tests.fixtures.services.economy_service import ( @@ -133,15 +135,17 @@ def capture(_service, setup_options): with patch.object(EconomyService, "_get_or_create_economic_impact", capture): result = economy_service.get_economic_impact( - country_id="us", - policy_id=MOCK_POLICY_ID, - baseline_policy_id=MOCK_BASELINE_POLICY_ID, - region="ca", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="ca", + dataset="default", + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target=target, + ), time_period="2025", - options=MOCK_OPTIONS, - api_version=MOCK_API_VERSION, - target=target, ) assert result == EconomicImpactResult.computing() @@ -152,15 +156,17 @@ def test_annual_submission_stores_record_and_sends_payload( economy_service, mock_simulation_entrypoint, mock_datetime ): result = economy_service.get_economic_impact( - country_id="us", - policy_id=MOCK_POLICY_ID, - baseline_policy_id=MOCK_BASELINE_POLICY_ID, - region="state/ca", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="state/ca", + dataset="default", + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target="cliff", + ), time_period="2025", - options=MOCK_OPTIONS, - api_version=MOCK_API_VERSION, - target="cliff", ) assert result == EconomicImpactResult.computing() @@ -255,15 +261,17 @@ def _budget_window_key(service: EconomyService) -> str: def _request_budget_window(service: EconomyService) -> dict: result = service.get_budget_window_economic_impact( - country_id="us", - policy_id=MOCK_POLICY_ID, - baseline_policy_id=MOCK_BASELINE_POLICY_ID, - region="ca", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region="ca", + dataset="default", + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + ), start_year="2026", window_size=3, - options=MOCK_OPTIONS, - api_version=MOCK_API_VERSION, max_active_years=2, ) return {**result.to_dict(), "cache_status": result.cache_status} @@ -396,7 +404,7 @@ def test_budget_window_poll_reports_running_years( ) def test_options_hash_lists_options_then_bundle_fields(bundle_fields, expected): options_hash = EconomyService()._build_options_hash( - options={"b": {"c": 2}, "a": 1}, **bundle_fields + options={"b": {"c": 2}, "a": 1}, bundle=RuntimeBundleIdentity(**bundle_fields) ) assert options_hash == expected @@ -405,9 +413,9 @@ def test_options_hash_lists_options_then_bundle_fields(bundle_fields, expected): def test_options_hash_marks_spm_provenance_schema(): options_hash = EconomyService()._build_options_hash( options={"spm": {"scenario": "x"}}, - model_version="1", - dataset="default", - runtime_app_name="app", + bundle=RuntimeBundleIdentity( + dataset="default", model_version="1", runtime_app_name="app" + ), ) assert options_hash == ( @@ -418,7 +426,7 @@ def test_options_hash_marks_spm_provenance_schema(): def test_empty_options_hash_drops_the_leading_separator(): options_hash = EconomyService()._build_options_hash( - options={}, model_version="1", dataset="default" + options={}, bundle=RuntimeBundleIdentity(dataset="default", model_version="1") ) assert options_hash == "[dataset=default&model_version=1]" diff --git a/tests/unit/services/test_worker_spm.py b/tests/unit/services/test_worker_spm.py index 04eae56e2..47d9bf79e 100644 --- a/tests/unit/services/test_worker_spm.py +++ b/tests/unit/services/test_worker_spm.py @@ -7,7 +7,12 @@ from policyengine_api.libs.simulation_entrypoint import SimulationEntrypointClient from policyengine_api.routes.economy_routes import economy_bp -from policyengine_api.services.economy_service import EconomyService +from policyengine_api.services.economic_impact_request import EconomicImpactRequest +from policyengine_api.services.economy_service import ( + EconomicImpactSetupOptions, + EconomyService, + RuntimeBundleIdentity, +) from policyengine_api.spm import SPMValidationError from policyengine_api.worker_spm import validate_worker_result, validate_worker_spm @@ -94,13 +99,15 @@ def test_canonical_economy_request_fails_before_cache_lookup(budget_window, opti simulation_entrypoint_=Mock(), ) arguments = { - "country_id": "us", - "policy_id": 1, - "baseline_policy_id": 2, - "region": "us", - "dataset": "default", - "options": options, - "api_version": "test", + "impact_request": EconomicImpactRequest( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + options=options, + api_version="test", + ), } if budget_window: call = service.get_budget_window_economic_impact @@ -230,32 +237,47 @@ def test_certified_worker_selection_enters_identity_and_submission(): "policyengine_api.worker_spm.normalize_spm_selection", return_value=SELECTION ): setup = service._build_economic_impact_setup_options( - country_id="us", - policy_id=1, - baseline_policy_id=2, - region="us", - dataset="default", + EconomicImpactRequest( + country_id="us", + policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + options={}, + api_version="test", + ), time_period="2026", - options={}, - api_version="test", ) assert setup.options["spm"] == SELECTION changed = {**SELECTION, "scenario": "zero_real"} + bundle = RuntimeBundleIdentity(dataset="default", model_version="test") assert service._build_options_hash( - options={"spm": SELECTION}, model_version="test", dataset="default" - ) != service._build_options_hash( - options={"spm": changed}, model_version="test", dataset="default" - ) + options={"spm": SELECTION}, bundle=bundle + ) != service._build_options_hash(options={"spm": changed}, bundle=bundle) assert ( "spm_provenance_schema=canonical-spm-comparison-v2" - in service._build_options_hash( - options={"spm": SELECTION}, model_version="test", dataset="default" - ) + in service._build_options_hash(options={"spm": SELECTION}, bundle=bundle) ) assert "spm_provenance_schema" not in service._build_options_hash( - options={}, model_version="test", dataset="default" + options={}, bundle=bundle + ) + options = service._setup_sim_options( + EconomicImpactSetupOptions( + submission_claim_id="claim", + country_id="us", + reform_policy_id=1, + baseline_policy_id=2, + region="us", + dataset="default", + time_period="2026", + options={"spm": SELECTION}, + api_version="test", + target="general", + ), + reform_policy={}, + baseline_policy={}, + time_period="2026", ) - options = service._setup_sim_options("us", {}, {}, "us", "2026", spm=SELECTION) assert options.model_dump(mode="json")["spm"] == SELECTION From 9514a93c780f813d691cad45ae822e11bffba009 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:51:23 +0400 Subject: [PATCH 07/10] Group household calculation options into one object prepare_household_calculation and calculate_household take a HouseholdCalculationOptions (add_missing, spm, spm_requested) instead of three keyword arguments; the calculate route builds it. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/routes/household_routes.py | 19 +++++----- .../services/household_calculation_service.py | 36 ++++++++++++------- .../test_household_calculation_options.py | 23 ++++++++---- 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index d2c7c9646..533130375 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -27,6 +27,7 @@ ) from policyengine_api.response_factory import _make_error_response from policyengine_api.services.household_calculation_service import ( + HouseholdCalculationOptions, HouseholdCalculationResult, HouseholdCalculationService, HouseholdNotFoundError, @@ -214,14 +215,16 @@ def wrapped( country_id, payload.get("household", {}), payload.get("policy", {}), - add_missing=add_missing, - **( - { - "spm": g.spm, - "spm_requested": g.spm_requested, - } - if g.spm is not None - else {} + options=HouseholdCalculationOptions( + add_missing=add_missing, + **( + { + "spm": g.spm, + "spm_requested": g.spm_requested, + } + if g.spm is not None + else {} + ), ), ) ) diff --git a/policyengine_api/services/household_calculation_service.py b/policyengine_api/services/household_calculation_service.py index 454b78190..5efb068b5 100644 --- a/policyengine_api/services/household_calculation_service.py +++ b/policyengine_api/services/household_calculation_service.py @@ -58,6 +58,24 @@ class HouseholdCalculationResult: spm_provenance: dict | None = None +@dataclass(frozen=True) +class HouseholdCalculationOptions: + """How to calculate a request-supplied household. + + ``add_missing`` adds every yearly, monthly and eternal variable the household + leaves out, ``spm`` is the SPM selection to calculate with, and + ``spm_requested`` says whether the caller sent that selection rather than + relying on the bundle's default. + """ + + add_missing: bool = False + spm: dict | None = None + spm_requested: bool = False + + +DEFAULT_CALCULATION_OPTIONS = HouseholdCalculationOptions() + + @dataclass(frozen=True) class PreparedHouseholdCalculation: """Validated request inputs ready for calculation after cache lookup.""" @@ -390,16 +408,14 @@ def prepare_household_calculation( household_json: dict, policy_json: dict, *, - add_missing: bool = False, - spm: dict | None = None, - spm_requested: bool = False, + options: HouseholdCalculationOptions = DEFAULT_CALCULATION_OPTIONS, ) -> PreparedHouseholdCalculation: """Validate request inputs before accepting a calculation.""" countries = self._countries() country = countries.get(country_id) - spm = normalize_spm_selection(country_id, spm) + spm = normalize_spm_selection(country_id, options.spm) household_json = deepcopy(household_json) - if add_missing: + if options.add_missing: household_json = add_yearly_variables( household_json, country_id, @@ -421,7 +437,7 @@ def prepare_household_calculation( household_json=household_json, policy_json=policy_json, spm=spm, - spm_requested=spm_requested, + spm_requested=options.spm_requested, warnings=tuple(warning.message for warning in deprecated_inputs.warnings), ) @@ -501,17 +517,13 @@ def calculate_household( household_json: dict, policy_json: dict, *, - add_missing: bool = False, - spm: dict | None = None, - spm_requested: bool = False, + options: HouseholdCalculationOptions = DEFAULT_CALCULATION_OPTIONS, ) -> HouseholdCalculationResult: """Validate and calculate request-provided household and policy data.""" prepared = self.prepare_household_calculation( country_id, household_json, policy_json, - add_missing=add_missing, - spm=spm, - spm_requested=spm_requested, + options=options, ) return self.calculate_prepared_household(prepared) diff --git a/tests/unit/services/test_household_calculation_options.py b/tests/unit/services/test_household_calculation_options.py index f64f30ff0..4104ce5fe 100644 --- a/tests/unit/services/test_household_calculation_options.py +++ b/tests/unit/services/test_household_calculation_options.py @@ -13,6 +13,7 @@ from policyengine_api.services import household_calculation_service from policyengine_api.services.household_calculation_service import ( CalculationResult, + HouseholdCalculationOptions, HouseholdCalculationResult, HouseholdCalculationService, PreparedHouseholdCalculation, @@ -128,7 +129,12 @@ def test_default_calculation_sends_the_household_unchanged(service, country): def test_add_missing_fills_yearly_and_monthly_variables(service, country): - service.calculate_household("us", deepcopy(HOUSEHOLD), {}, add_missing=True) + service.calculate_household( + "us", + deepcopy(HOUSEHOLD), + {}, + options=HouseholdCalculationOptions(add_missing=True), + ) assert country.requests == [ ( @@ -155,8 +161,9 @@ def test_spm_selection_and_request_flag_reach_the_country( "us", deepcopy(HOUSEHOLD), {}, - spm=SPM_CONFIG, - spm_requested=spm_requested, + options=HouseholdCalculationOptions( + spm=SPM_CONFIG, spm_requested=spm_requested + ), ) assert country.requests == [ @@ -170,9 +177,9 @@ def test_prepared_calculation_keeps_every_option(service, country): "us", deepcopy(HOUSEHOLD), {}, - add_missing=True, - spm=SPM_CONFIG, - spm_requested=True, + options=HouseholdCalculationOptions( + add_missing=True, spm=SPM_CONFIG, spm_requested=True + ), ) assert prepared == PreparedHouseholdCalculation( @@ -197,6 +204,8 @@ def test_prepared_calculation_keeps_every_option(service, country): def test_preparing_does_not_change_the_callers_household(service): household = deepcopy(HOUSEHOLD) - service.prepare_household_calculation("us", household, {}, add_missing=True) + service.prepare_household_calculation( + "us", household, {}, options=HouseholdCalculationOptions(add_missing=True) + ) assert household == HOUSEHOLD From 851f226dbb69528e731af7d48ff99dea340dbd00 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:51:31 +0400 Subject: [PATCH 08/10] Type the UK area rows as the breakdown models' input The per-area rows are plain dicts that the breakdown models validate, so type them as dict[str, Any] rather than dict, which mypy reads as a mismatch with the models' field types. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/services/economy_comparison.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_api/services/economy_comparison.py b/policyengine_api/services/economy_comparison.py index e51a7c3bc..c3012c783 100644 --- a/policyengine_api/services/economy_comparison.py +++ b/policyengine_api/services/economy_comparison.py @@ -2,7 +2,7 @@ import logging from collections.abc import Callable -from typing import NamedTuple +from typing import Any, NamedTuple import h5py import numpy as np @@ -719,13 +719,13 @@ def _uk_area_breakdown( reform_net_income: object, area_data: _UKAreaData, area_filter: _UKAreaFilter, -) -> tuple[dict[str, dict], dict[str, dict[str, int]]]: +) -> tuple[dict[str, Any], dict[str, dict[str, int]]]: """Return each selected area's income change and the bucket counts by region. The first mapping is keyed by area name in table order; the second counts the selected areas in the UK and each nation by income-change bucket. """ - by_area: dict[str, dict] = {} + by_area: dict[str, Any] = {} outcomes_by_region = { region_name: dict.fromkeys(INCOME_CHANGE_BUCKETS, 0) for region_name in UK_OUTCOME_REGIONS From 5a6b5599d86d7162771ce62caaab49c735462aa7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:08:33 +0400 Subject: [PATCH 09/10] Record economy comparison logs from the module logger in snapshots The Alembic tests load a logging configuration that disables existing loggers, so in a full run the comparison module's logger no longer reached caplog. Attach a recording handler to that logger, enabled for the test's duration, so the snapshot sees what the module logs in any test order. Co-Authored-By: Claude Opus 5.5 --- .../test_economy_comparison_snapshots.py | 63 ++++++++++++++----- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/tests/unit/services/test_economy_comparison_snapshots.py b/tests/unit/services/test_economy_comparison_snapshots.py index 93aeb713a..27a6e6e2b 100644 --- a/tests/unit/services/test_economy_comparison_snapshots.py +++ b/tests/unit/services/test_economy_comparison_snapshots.py @@ -270,6 +270,39 @@ def uk_datasets() -> Iterator[FakeUKDatasets]: yield datasets +class _RecordingHandler(logging.Handler): + """Keep every record logged to the logger it is attached to.""" + + def __init__(self, records: list[logging.LogRecord]) -> None: + super().__init__(level=logging.DEBUG) + self.records = records + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +@pytest.fixture +def comparison_logs() -> Iterator[list[logging.LogRecord]]: + """Record what the comparison module logs, whatever the global logging setup. + + Other tests load logging configuration that disables existing loggers, so + the module logger is enabled and recorded directly for the test's duration. + """ + records: list[logging.LogRecord] = [] + handler = _RecordingHandler(records) + logger = economy_comparison.logger + previous_state = (logger.disabled, logger.level) + logger.disabled = False + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + try: + yield records + finally: + logger.removeHandler(handler) + logger.disabled, previous_level = previous_state + logger.setLevel(previous_level) + + def _canonical_key(key: object) -> str: """Keep string keys as they are and tag any other key with its type.""" if isinstance(key, str): @@ -317,7 +350,7 @@ def _key_order(value: object, path: str = "") -> dict[str, list[str]]: def _observed( result: object, datasets: FakeUKDatasets | None, - caplog: pytest.LogCaptureFixture, + comparison_logs: list[logging.LogRecord], ) -> dict[str, Any]: return { "result_type": type(result).__name__, @@ -331,7 +364,7 @@ def _observed( record.getMessage(), record.exc_info[0].__name__ if record.exc_info else None, ] - for record in caplog.records + for record in comparison_logs if record.name.startswith("policyengine_api") ], } @@ -390,7 +423,7 @@ def _assert_matches_snapshot(case_name: str, observed: dict[str, Any]) -> None: @pytest.mark.parametrize("case_name", sorted(GENERAL_CASES)) def test_compare_economic_outputs_general_matches_snapshot( - case_name, uk_datasets, caplog + case_name, uk_datasets, comparison_logs ): country_id, region, include_wealth, use_numpy = GENERAL_CASES[case_name] economy_country = country_id or "us" @@ -402,11 +435,10 @@ def test_compare_economic_outputs_general_matches_snapshot( ) if use_numpy: baseline, reform = _numpy_economy(baseline), _numpy_economy(reform) - caplog.set_level(logging.DEBUG) result = compare_economic_outputs(baseline, reform, country_id, region) - _assert_matches_snapshot(case_name, _observed(result, uk_datasets, caplog)) + _assert_matches_snapshot(case_name, _observed(result, uk_datasets, comparison_logs)) @pytest.mark.parametrize( @@ -419,7 +451,7 @@ def test_compare_economic_outputs_general_matches_snapshot( ], ) def test_compare_economic_outputs_other_types_match_snapshot( - case_name, baseline_type, country_id, caplog + case_name, baseline_type, country_id, comparison_logs ): baseline = _cliff_economy(side="baseline") reform = _cliff_economy(side="reform") @@ -427,11 +459,10 @@ def test_compare_economic_outputs_other_types_match_snapshot( del baseline["type"] else: baseline["type"] = baseline_type - caplog.set_level(logging.DEBUG) result = compare_economic_outputs(baseline, reform, country_id, None) - _assert_matches_snapshot(case_name, _observed(result, None, caplog)) + _assert_matches_snapshot(case_name, _observed(result, None, comparison_logs)) BREAKDOWN_REGIONS = [ @@ -470,16 +501,15 @@ def _uk_breakdown_economies() -> tuple[dict[str, Any], dict[str, Any]]: ) @pytest.mark.parametrize("region", BREAKDOWN_REGIONS) def test_uk_area_breakdowns_match_snapshot( - breakdown_name, breakdown, region, uk_datasets, caplog + breakdown_name, breakdown, region, uk_datasets, comparison_logs ): baseline, reform = _uk_breakdown_economies() - caplog.set_level(logging.DEBUG) result = breakdown(baseline, reform, "uk", region) _assert_matches_snapshot( f"{breakdown_name}_breakdown_region_{region}", - _observed(result, uk_datasets, caplog), + _observed(result, uk_datasets, comparison_logs), ) @@ -492,7 +522,7 @@ def test_uk_area_breakdowns_match_snapshot( ) @pytest.mark.parametrize("country_id", ["us", None, "UK"]) def test_uk_area_breakdowns_outside_uk_match_snapshot( - breakdown_name, breakdown, country_id, uk_datasets, caplog + breakdown_name, breakdown, country_id, uk_datasets, comparison_logs ): baseline, reform = _uk_breakdown_economies() @@ -500,11 +530,13 @@ def test_uk_area_breakdowns_outside_uk_match_snapshot( _assert_matches_snapshot( f"{breakdown_name}_breakdown_country_{country_id}", - _observed(result, uk_datasets, caplog), + _observed(result, uk_datasets, comparison_logs), ) -def test_uk_area_breakdowns_with_numpy_incomes_match_snapshot(uk_datasets, caplog): +def test_uk_area_breakdowns_with_numpy_incomes_match_snapshot( + uk_datasets, comparison_logs +): baseline, reform = _uk_breakdown_economies() baseline = _numpy_economy(baseline) reform = _numpy_economy(reform) @@ -515,7 +547,8 @@ def test_uk_area_breakdowns_with_numpy_incomes_match_snapshot(uk_datasets, caplo } _assert_matches_snapshot( - "area_breakdowns_numpy_incomes", _observed(results, uk_datasets, caplog) + "area_breakdowns_numpy_incomes", + _observed(results, uk_datasets, comparison_logs), ) From 96d245417716530e4d063e0acc4726a15385a730 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:28:46 +0400 Subject: [PATCH 10/10] Add a changelog fragment for the economy complexity refactors Co-Authored-By: Claude Opus 5.5 --- changelog.d/ruff-complexity-economy.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/ruff-complexity-economy.changed.md diff --git a/changelog.d/ruff-complexity-economy.changed.md b/changelog.d/ruff-complexity-economy.changed.md new file mode 100644 index 000000000..aaa26df17 --- /dev/null +++ b/changelog.d/ruff-complexity-economy.changed.md @@ -0,0 +1 @@ +Refactored the economy comparison and service, the reform-impact and budget-window caches, the SPM and worker-SPM modules and the household calculation service under ruff's complexity rules (C901, PLR): long functions are split into named steps, related arguments travel as small value objects, and magic numbers are named, with characterization tests pinning the behaviour first; responses, cache keys, stored records and published schemas are unchanged.