From 462c52ee3ebfd09cd34e0f113d8c19c5f750328b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 19 Apr 2026 08:00:21 -0400 Subject: [PATCH 1/2] Partition US budgetary impact into federal, state, and unattributed shares Add BudgetaryImpact (federal / state / unattributed, plus a computed total) on PolicyReformAnalysis and a standalone calculate_budgetary_impact helper. The arithmetic lives here, not in policyengine-api, so every consumer -- API, notebooks, ad-hoc scripts -- reuses one implementation. total is measured directly as the change in household_tax minus the change in household_benefits, so it covers every tax and benefit the model computes. federal and state attribute only the cleanly-assignable pieces: federal = d(income_tax) + d(employee_payroll_tax) - d(federal_benefit_cost) state = d(state_income_tax) - d(state_benefit_cost) unattributed is the residual (total - federal - state), so a reform to a 100%-federal program such as SSI or SNAP surfaces in total/unattributed instead of silently reading as zero. total is a pydantic computed_field, so federal + state + unattributed always equals it by construction. Addresses review feedback on #296: - Use employee_payroll_tax; payroll_tax was removed from policyengine-us. - Make total the real household_tax - household_benefits and expose the unattributed residual, instead of total = federal + state (which hid the impact of unattributed programs like an SSI reform). - Add an integration test that resolves every referenced variable against the installed policyengine-us model so future renames fail loudly. federal_benefit_cost / state_benefit_cost are the shared-funding aggregates from PolicyEngine/policyengine-us#8076, already present in the pinned policyengine-us 1.752.2, so no pin bump is required. They are not in the default US output, so economic_impact_analysis calls configure_budgetary_impact_variables to materialize them before running, mirroring how labor-supply and cliff-impact configure their extra outputs. Closes #289. Co-Authored-By: Claude Fable 5 --- changelog.d/289.added.md | 1 + examples/us_budgetary_impact.py | 7 + .../tax_benefit_models/us/__init__.py | 8 +- .../tax_benefit_models/us/analysis.py | 153 +++++++- tests/test_budgetary_impact.py | 339 ++++++++++++++++++ tests/test_cliff_impact_analysis.py | 14 + tests/test_labor_supply_response.py | 7 +- 7 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 changelog.d/289.added.md create mode 100644 tests/test_budgetary_impact.py diff --git a/changelog.d/289.added.md b/changelog.d/289.added.md new file mode 100644 index 00000000..d61c4778 --- /dev/null +++ b/changelog.d/289.added.md @@ -0,0 +1 @@ +Partition US policy reform budgetary impact into federal, state, and unattributed shares via `BudgetaryImpact` on `PolicyReformAnalysis` and the standalone `calculate_budgetary_impact` helper. `total` is the change in `household_tax` minus `household_benefits` (every tax and benefit the model computes); `federal` and `state` attribute the cleanly-assignable pieces (`income_tax` + `employee_payroll_tax` − `federal_benefit_cost`; `state_income_tax` − `state_benefit_cost`), and `unattributed` carries the residual so a reform to a 100%-federal program such as SSI or SNAP is not silently reported as a near-zero total. `economic_impact_analysis` materializes the `federal_benefit_cost` / `state_benefit_cost` aggregates automatically. diff --git a/examples/us_budgetary_impact.py b/examples/us_budgetary_impact.py index 90a0fbb2..f52b835f 100644 --- a/examples/us_budgetary_impact.py +++ b/examples/us_budgetary_impact.py @@ -117,6 +117,13 @@ def main(): print("\nRunning full economic impact analysis...") analysis = economic_impact_analysis(baseline_sim, reform_sim) + print("\n=== Budgetary Impact (Federal / State / Unattributed) ===") + b = analysis.budgetary_impact + print(f" Federal: ${b.federal / 1e9:+8.1f}B") + print(f" State: ${b.state / 1e9:+8.1f}B") + print(f" Unattributed: ${b.unattributed / 1e9:+8.1f}B") + print(f" Total: ${b.total / 1e9:+8.1f}B") + print("\n=== Program-by-Program Impact ===") for prog in analysis.program_statistics.outputs: print( diff --git a/src/policyengine/tax_benefit_models/us/__init__.py b/src/policyengine/tax_benefit_models/us/__init__.py index bc0f6be9..d98ba089 100644 --- a/src/policyengine/tax_benefit_models/us/__init__.py +++ b/src/policyengine/tax_benefit_models/us/__init__.py @@ -30,7 +30,11 @@ from policyengine.core import Dataset from policyengine.outputs import LaborSupplyResponse, ProgramStatistics - from .analysis import economic_impact_analysis + from .analysis import ( + BudgetaryImpact, + calculate_budgetary_impact, + economic_impact_analysis, + ) from .datasets import ( PolicyEngineUSDataset, USYearData, @@ -75,6 +79,8 @@ "us_latest", "calculate_household", "economic_impact_analysis", + "calculate_budgetary_impact", + "BudgetaryImpact", "ProgramStatistics", "LaborSupplyResponse", ] diff --git a/src/policyengine/tax_benefit_models/us/analysis.py b/src/policyengine/tax_benefit_models/us/analysis.py index 1d4bb5d1..74096ff3 100644 --- a/src/policyengine/tax_benefit_models/us/analysis.py +++ b/src/policyengine/tax_benefit_models/us/analysis.py @@ -9,7 +9,7 @@ from typing import Union import pandas as pd -from pydantic import BaseModel +from pydantic import BaseModel, Field, computed_field from policyengine.core import OutputCollection, Simulation from policyengine.outputs import ( @@ -21,10 +21,15 @@ configure_cliff_impact_variables, configure_labor_supply_response_variables, ) +from policyengine.outputs.change_aggregate import ( + ChangeAggregate, + ChangeAggregateType, +) from policyengine.outputs.decile_impact import ( DecileImpact, calculate_decile_impacts, ) +from policyengine.outputs.extra_variables import add_extra_variables from policyengine.outputs.inequality import ( Inequality, USInequalityPreset, @@ -56,11 +61,151 @@ } +class BudgetaryImpact(BaseModel): + """Federal/state/unattributed partition of a US reform's budgetary impact. + + Sign convention: positive means the government is better off (more tax + revenue or lower benefit spending); negative means revenue lost or + spending incurred. + + ``total`` is measured directly from the reform-minus-baseline change in + ``household_tax`` minus the change in ``household_benefits``, so it covers + every tax and benefit the model computes. ``federal`` and ``state`` hold + only the pieces that are cleanly attributable to a level of government + today; ``unattributed`` is the residual (``total - federal - state``) that + carries everything not yet split. ``total`` is a computed field, so + ``federal + state + unattributed`` always equals it by construction. See + :func:`calculate_budgetary_impact` for the exact variables behind each + share. + """ + + federal: float = Field( + ..., + description="Federal budgetary impact, USD (positive = government gain).", + ) + state: float = Field( + ..., + description="State budgetary impact, USD (positive = government gain).", + ) + unattributed: float = Field( + ..., + description=( + "Budgetary impact not yet attributed to a level of government, " + "USD. Residual of total minus federal minus state." + ), + ) + + @computed_field # type: ignore[prop-decorator] + @property + def total(self) -> float: + """Total budgetary impact, USD: federal + state + unattributed.""" + return self.federal + self.state + self.unattributed + + +def _sum_change( + baseline_simulation: Simulation, + reform_simulation: Simulation, + variable: str, +) -> float: + """Reform minus baseline total for a variable.""" + agg = ChangeAggregate( + baseline_simulation=baseline_simulation, + reform_simulation=reform_simulation, + variable=variable, + aggregate_type=ChangeAggregateType.SUM, + ) + agg.run() + return float(agg.result) + + +# Budgetary-impact variables that are not in the default US output +# (household_tax, household_benefits, and the three tax variables are). They +# must be materialized before the reform simulations run. +_BUDGETARY_IMPACT_EXTRA_VARIABLES: dict[str, list[str]] = { + "person": ["federal_benefit_cost", "state_benefit_cost"], +} + + +def configure_budgetary_impact_variables( + baseline_simulation: Simulation, + reform_simulation: Simulation, +) -> None: + """Materialize the budgetary-impact aggregates in the reform outputs. + + ``federal_benefit_cost`` / ``state_benefit_cost`` are not in the default US + output, so they are added as extra output variables before the simulations + run. Call this before :meth:`Simulation.ensure` so the aggregates are + present when :func:`calculate_budgetary_impact` reads them; + :func:`economic_impact_analysis` does this automatically. + """ + add_extra_variables(baseline_simulation, _BUDGETARY_IMPACT_EXTRA_VARIABLES) + add_extra_variables(reform_simulation, _BUDGETARY_IMPACT_EXTRA_VARIABLES) + + +def calculate_budgetary_impact( + baseline_simulation: Simulation, + reform_simulation: Simulation, +) -> BudgetaryImpact: + """Partition a US reform's budgetary impact into federal, state, and + unattributed shares. + + All figures are reform-minus-baseline weighted totals. Sign convention: + positive means the government is better off (more tax revenue or lower + benefit spending). + + ``total`` is measured directly from the model's household aggregates:: + + total = Δhousehold_tax - Δhousehold_benefits + + so it captures *every* tax and benefit the model computes, regardless of + whether the affected program can be attributed to a level of government. + + ``federal`` and ``state`` attribute only the cleanly-assignable pieces:: + + federal = Δincome_tax + Δemployee_payroll_tax - Δfederal_benefit_cost + state = Δstate_income_tax - Δstate_benefit_cost + + where ``federal_benefit_cost`` / ``state_benefit_cost`` are the + policyengine-us aggregates that split shared-funding programs (Medicaid + FMAP, CHIP eFMAP) into their federal and state portions. + + ``unattributed`` is the residual, ``total - federal - state``. It carries + everything not yet split by level of government — 100%-federal programs + such as SSI and SNAP, 100%-state supplements, and any tax outside the + three tax variables above — until finer attribution exists. Because + ``total`` is measured from ``household_tax`` / ``household_benefits`` + rather than summed from the attributed pieces, a reform to an + unattributed program (for example SSI) surfaces in ``total`` and + ``unattributed`` instead of silently reading as zero. + + ``federal_benefit_cost`` / ``state_benefit_cost`` are not in the default US + output; :func:`economic_impact_analysis` calls + :func:`configure_budgetary_impact_variables` to materialize them before the + simulations run. Callers using this helper directly must do the same. + """ + total = _sum_change( + baseline_simulation, reform_simulation, "household_tax" + ) - _sum_change(baseline_simulation, reform_simulation, "household_benefits") + + federal = ( + _sum_change(baseline_simulation, reform_simulation, "income_tax") + + _sum_change(baseline_simulation, reform_simulation, "employee_payroll_tax") + - _sum_change(baseline_simulation, reform_simulation, "federal_benefit_cost") + ) + state = _sum_change( + baseline_simulation, reform_simulation, "state_income_tax" + ) - _sum_change(baseline_simulation, reform_simulation, "state_benefit_cost") + + unattributed = total - federal - state + return BudgetaryImpact(federal=federal, state=state, unattributed=unattributed) + + class PolicyReformAnalysis(BaseModel): """Complete policy reform analysis result.""" decile_impacts: OutputCollection[DecileImpact] program_statistics: OutputCollection[ProgramStatistics] + budgetary_impact: BudgetaryImpact baseline_poverty: OutputCollection[Poverty] reform_poverty: OutputCollection[Poverty] baseline_inequality: Inequality @@ -158,6 +303,7 @@ def economic_impact_analysis( ) if include_cliff_impacts: configure_cliff_impact_variables(baseline_simulation, reform_simulation) + configure_budgetary_impact_variables(baseline_simulation, reform_simulation) _validate_program_statistics_config(baseline_simulation, reform_simulation) baseline_simulation.ensure() @@ -231,9 +377,14 @@ def economic_impact_analysis( else None ) + budgetary_impact = calculate_budgetary_impact( + baseline_simulation, reform_simulation + ) + return PolicyReformAnalysis( decile_impacts=decile_impacts, program_statistics=program_collection, + budgetary_impact=budgetary_impact, baseline_poverty=baseline_poverty, reform_poverty=reform_poverty, baseline_inequality=baseline_inequality, diff --git a/tests/test_budgetary_impact.py b/tests/test_budgetary_impact.py new file mode 100644 index 00000000..7244d60b --- /dev/null +++ b/tests/test_budgetary_impact.py @@ -0,0 +1,339 @@ +"""Tests for federal/state/unattributed budgetary impact partitioning. + +The unit tests patch ``_sum_change`` and feed mocked reform-minus-baseline +deltas, so they exercise the partition arithmetic in isolation. The +integration test at the bottom runs ``calculate_budgetary_impact`` against a +tiny hand-built US output simulation so that every variable name it references +is resolved against the installed policyengine-us model — a future rename +(as happened with ``payroll_tax`` -> ``employee_payroll_tax``) fails loudly +here instead of silently returning a wrong number. +""" + +from unittest.mock import patch + +import pandas as pd +from microdf import MicroDataFrame + +from policyengine.core import Simulation +from policyengine.tax_benefit_models.us.analysis import ( + BudgetaryImpact, + calculate_budgetary_impact, + configure_budgetary_impact_variables, +) +from policyengine.tax_benefit_models.us.datasets import ( + PolicyEngineUSDataset, + USYearData, +) +from policyengine.tax_benefit_models.us.model import us_latest + +# Variables that calculate_budgetary_impact reads. Kept here so the +# integration test can assert each one still resolves against the installed +# policyengine-us model. +BUDGETARY_IMPACT_VARIABLES = ( + "household_tax", + "household_benefits", + "income_tax", + "employee_payroll_tax", + "state_income_tax", + "federal_benefit_cost", + "state_benefit_cost", +) + + +def _fake_sum_change_factory(variable_to_delta: dict[str, float]): + """Return a fake _sum_change that looks up deltas by variable name.""" + + def fake_sum_change(baseline_sim, reform_sim, variable): + return variable_to_delta.get(variable, 0.0) + + return fake_sum_change + + +def _budgetary_impact_from_deltas(deltas: dict[str, float]) -> BudgetaryImpact: + with patch( + "policyengine.tax_benefit_models.us.analysis._sum_change", + side_effect=_fake_sum_change_factory(deltas), + ): + return calculate_budgetary_impact(None, None) + + +def test_federal_income_tax_cut_is_fully_attributed(): + """A pure federal income tax cut lands entirely in ``federal``. + + ``household_tax`` moves with ``income_tax``, so nothing is left over: + the residual is zero. + """ + result = _budgetary_impact_from_deltas( + { + "income_tax": -100e9, + "household_tax": -100e9, + } + ) + + assert isinstance(result, BudgetaryImpact) + assert result.federal == -100e9 + assert result.state == 0 + assert result.unattributed == 0 + assert result.total == -100e9 + + +def test_shared_benefit_rollback_splits_federal_and_state(): + """Repealing ACA expansion cuts federal benefit spending 9x the state cut + (90% vs 10% FMAP). Reduced spending is a positive fiscal impact, and + because Medicaid is fully captured by the shared-funding aggregates the + residual is zero.""" + result = _budgetary_impact_from_deltas( + { + # Federal share of Medicaid cost drops $90B, state share $10B. + "federal_benefit_cost": -90e9, + "state_benefit_cost": -10e9, + # household_benefits falls by the full $100B program cost. + "household_benefits": -100e9, + } + ) + + # Federal "impact" = -(-90B) = +90B (government saves money). + assert result.federal == 90e9 + assert result.state == 10e9 + assert result.unattributed == 0 + assert result.total == 100e9 + + +def test_unattributed_program_surfaces_in_residual_not_zero(): + """An SSI reform must not silently read as total ~= 0. + + SSI is 100% federal but is not in ``federal_benefit_cost``, so ``federal`` + and ``state`` stay zero. Because ``total`` is measured from + ``household_benefits`` (which does include SSI), the impact appears in + ``total`` and flows entirely into ``unattributed``. + """ + result = _budgetary_impact_from_deltas( + { + # SSI benefits rise $30B; households receive more. + "household_benefits": 30e9, + } + ) + + assert result.federal == 0 + assert result.state == 0 + # Higher benefit spending = negative fiscal impact, carried by residual. + assert result.unattributed == -30e9 + assert result.total == -30e9 + + +def test_mixed_reform_partitions_with_nonzero_residual(): + """Federal + state tax cuts, a shared-benefit cut, and an unattributed + (SSI) benefit increase partition into all three buckets.""" + result = _budgetary_impact_from_deltas( + { + "income_tax": -50e9, + "employee_payroll_tax": -10e9, + "state_income_tax": -20e9, + "federal_benefit_cost": -5e9, + "state_benefit_cost": -2e9, + # household_tax falls by the three tax cuts: 50 + 10 + 20. + "household_tax": -80e9, + # household_benefits nets a $7B shared-program cut and an $8B SSI + # rise: -7 + 8 = +1. + "household_benefits": 1e9, + } + ) + + # Federal = -50 + -10 - (-5) = -55. + assert result.federal == -55e9 + # State = -20 - (-2) = -18. + assert result.state == -18e9 + # Total = -80 - 1 = -81. + assert result.total == -81e9 + # Residual = -81 - (-55) - (-18) = -8, i.e. the $8B SSI spending rise. + assert result.unattributed == -8e9 + + +def test_zero_reform_gives_zero_impact(): + result = _budgetary_impact_from_deltas({}) + + assert result.federal == 0 + assert result.state == 0 + assert result.unattributed == 0 + assert result.total == 0 + + +def test_total_is_always_the_sum_of_parts(): + """The computed ``total`` field is federal + state + unattributed.""" + result = BudgetaryImpact(federal=12.0, state=3.0, unattributed=-5.0) + assert result.total == 10.0 + # JSON round-trip keeps the invariant. + restored = BudgetaryImpact.model_validate_json(result.model_dump_json()) + assert restored.total == result.total + + +# --------------------------------------------------------------------------- +# Integration test: real variable-name resolution against policyengine-us. +# --------------------------------------------------------------------------- + + +def _microdf(data: dict, weights: str) -> MicroDataFrame: + return MicroDataFrame(pd.DataFrame(data), weights=weights) + + +def _make_us_output_simulation( + tmp_path, + simulation_id: str, + *, + income_tax: float = 0.0, + employee_payroll_tax: float = 0.0, + state_income_tax: float = 0.0, + federal_benefit_cost: float = 0.0, + state_benefit_cost: float = 0.0, + household_tax: float = 0.0, + household_benefits: float = 0.0, +) -> Simulation: + """Build a two-record US output simulation with the budgetary-impact + variables materialized at their real entities. + + Mirrors the pattern in tests/test_us_program_statistics.py: all non-zero + values sit in the first record (weight 1.0), so each weighted total equals + the value passed in. + """ + data = USYearData( + person=_microdf( + { + "person_id": [1, 2], + "household_id": [1, 2], + "marital_unit_id": [1, 2], + "family_id": [1, 2], + "spm_unit_id": [1, 2], + "tax_unit_id": [1, 2], + "person_weight": [1.0, 2.0], + "federal_benefit_cost": [federal_benefit_cost, 0.0], + "state_benefit_cost": [state_benefit_cost, 0.0], + }, + "person_weight", + ), + marital_unit=_microdf( + { + "marital_unit_id": [1, 2], + "marital_unit_weight": [1.0, 2.0], + }, + "marital_unit_weight", + ), + family=_microdf( + { + "family_id": [1, 2], + "family_weight": [1.0, 2.0], + }, + "family_weight", + ), + spm_unit=_microdf( + { + "spm_unit_id": [1, 2], + "spm_unit_weight": [1.0, 2.0], + }, + "spm_unit_weight", + ), + tax_unit=_microdf( + { + "tax_unit_id": [1, 2], + "tax_unit_weight": [1.0, 2.0], + "income_tax": [income_tax, 0.0], + "employee_payroll_tax": [employee_payroll_tax, 0.0], + "state_income_tax": [state_income_tax, 0.0], + }, + "tax_unit_weight", + ), + household=_microdf( + { + "household_id": [1, 2], + "household_weight": [1.0, 2.0], + "household_tax": [household_tax, 0.0], + "household_benefits": [household_benefits, 0.0], + }, + "household_weight", + ), + ) + dataset = PolicyEngineUSDataset( + id=simulation_id, + name=f"{simulation_id} output", + description="Mocked US output dataset for budgetary impact", + filepath=str(tmp_path / f"{simulation_id}.h5"), + year=2026, + is_output_dataset=True, + data=data, + ) + return Simulation( + id=simulation_id, + dataset=dataset, + tax_benefit_model_version=us_latest, + output_dataset=dataset, + ) + + +def test_budgetary_impact_variables_resolve_against_model(): + """Every variable calculate_budgetary_impact reads must exist in the + installed policyengine-us model, so a rename fails loudly here.""" + for variable in BUDGETARY_IMPACT_VARIABLES: + assert variable in us_latest.variables_by_name, ( + f"{variable} is not defined in the installed policyengine-us model" + ) + + +def test_configure_budgetary_impact_variables_materializes_aggregates(tmp_path): + """federal_benefit_cost / state_benefit_cost are not in the default US + output, so economic_impact_analysis materializes them via + configure_budgetary_impact_variables before running. Without this wiring + calculate_budgetary_impact would raise on real data, so guard it here. + """ + baseline = _make_us_output_simulation(tmp_path, "baseline") + reform = _make_us_output_simulation(tmp_path, "reform") + + configure_budgetary_impact_variables(baseline, reform) + + for simulation in (baseline, reform): + person_extras = simulation.extra_variables["person"] + assert "federal_benefit_cost" in person_extras + assert "state_benefit_cost" in person_extras + + +def test_calculate_budgetary_impact_runs_against_real_output_simulation(tmp_path): + """End-to-end: resolve every referenced variable against a real US model + version and confirm the partition arithmetic on tiny output data. + + If policyengine-us renames any of the referenced variables, + ``ChangeAggregate`` raises inside ``calculate_budgetary_impact`` and this + test fails, rather than the reform analysis silently breaking. + """ + baseline = _make_us_output_simulation( + tmp_path, + "baseline", + income_tax=1000.0, + employee_payroll_tax=500.0, + state_income_tax=300.0, + federal_benefit_cost=200.0, + state_benefit_cost=50.0, + household_tax=1800.0, + household_benefits=250.0, + ) + reform = _make_us_output_simulation( + tmp_path, + "reform", + income_tax=900.0, + employee_payroll_tax=480.0, + state_income_tax=280.0, + federal_benefit_cost=180.0, + state_benefit_cost=45.0, + household_tax=1660.0, + household_benefits=235.0, + ) + + result = calculate_budgetary_impact(baseline, reform) + + assert isinstance(result, BudgetaryImpact) + # federal = Δincome_tax + Δemployee_payroll_tax - Δfederal_benefit_cost + # = -100 + -20 - (-20) = -100 + assert result.federal == -100.0 + # state = Δstate_income_tax - Δstate_benefit_cost = -20 - (-5) = -15 + assert result.state == -15.0 + # total = Δhousehold_tax - Δhousehold_benefits = -140 - (-15) = -125 + assert result.total == -125.0 + # unattributed = total - federal - state = -125 - (-100) - (-15) = -10 + assert result.unattributed == -10.0 diff --git a/tests/test_cliff_impact_analysis.py b/tests/test_cliff_impact_analysis.py index ab2a0b3d..5f6a4f19 100644 --- a/tests/test_cliff_impact_analysis.py +++ b/tests/test_cliff_impact_analysis.py @@ -116,6 +116,20 @@ def fake_program_statistics(**kwargs): "calculate_us_inequality", lambda simulation, preset: _empty_inequality(simulation), ) + monkeypatch.setattr( + analysis_module, + "configure_budgetary_impact_variables", + lambda baseline_simulation, reform_simulation: None, + ) + monkeypatch.setattr( + analysis_module, + "calculate_budgetary_impact", + lambda baseline_simulation, reform_simulation: ( + analysis_module.BudgetaryImpact( + federal=0.0, state=0.0, unattributed=0.0 + ) + ), + ) if fail_on_cliff: diff --git a/tests/test_labor_supply_response.py b/tests/test_labor_supply_response.py index 7e6389f0..09220913 100644 --- a/tests/test_labor_supply_response.py +++ b/tests/test_labor_supply_response.py @@ -655,7 +655,12 @@ class StopAfterEnsure(Exception): pass def fake_ensure(self): - assert self.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + # economic_impact_analysis also configures budgetary-impact extras on + # the person entity, so assert the LSR extras are present rather than + # requiring them to be the exact person extra-variable list. + assert set(US_EXPECTED_LSR_EXTRA_VARIABLES).issubset( + self.extra_variables["person"] + ) ensure_calls.append(self.id) if len(ensure_calls) == 2: raise StopAfterEnsure From 46c0ca2db151463cfe2c39b06d567e65ab38182e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Jul 2026 10:37:37 -0400 Subject: [PATCH 2/2] Count shared-funding health-program cost in the budgetary total Medicaid, CHIP, and MSP are excluded from household_benefits by default (gov.simulation.include_health_benefits_in_net_income is false), so a reform to those programs previously reported total ~= 0 with a phantom offsetting unattributed residual. total now adds the change in federal_benefit_cost + state_benefit_cost, guarded against double-counting when the parameter folds health programs into household_benefits. Tests use model-consistent deltas and cover the guard path. Co-Authored-By: Claude Fable 5 --- changelog.d/289.added.md | 2 +- .../tax_benefit_models/us/analysis.py | 108 +++++++++++++----- tests/test_budgetary_impact.py | 64 ++++++++--- 3 files changed, 128 insertions(+), 46 deletions(-) diff --git a/changelog.d/289.added.md b/changelog.d/289.added.md index d61c4778..fc7f3855 100644 --- a/changelog.d/289.added.md +++ b/changelog.d/289.added.md @@ -1 +1 @@ -Partition US policy reform budgetary impact into federal, state, and unattributed shares via `BudgetaryImpact` on `PolicyReformAnalysis` and the standalone `calculate_budgetary_impact` helper. `total` is the change in `household_tax` minus `household_benefits` (every tax and benefit the model computes); `federal` and `state` attribute the cleanly-assignable pieces (`income_tax` + `employee_payroll_tax` − `federal_benefit_cost`; `state_income_tax` − `state_benefit_cost`), and `unattributed` carries the residual so a reform to a 100%-federal program such as SSI or SNAP is not silently reported as a near-zero total. `economic_impact_analysis` materializes the `federal_benefit_cost` / `state_benefit_cost` aggregates automatically. +Partition US policy reform budgetary impact into federal, state, and unattributed shares via `BudgetaryImpact` on `PolicyReformAnalysis` and the standalone `calculate_budgetary_impact` helper. `total` combines the change in `household_tax` and `household_benefits` with the change in the shared-funding health-program government cost (Medicaid FMAP, CHIP eFMAP, and Medicare Savings Programs), which `household_benefits` excludes by default — guarded against double-counting when `gov.simulation.include_health_benefits_in_net_income` is enabled. `federal` and `state` attribute the cleanly-assignable pieces (`income_tax` + `employee_payroll_tax` − `federal_benefit_cost`; `state_income_tax` − `state_benefit_cost`), and `unattributed` carries the residual so reforms to 100%-federal programs such as SSI or SNAP and to shared-funding health programs both surface instead of silently reading as near-zero totals. `economic_impact_analysis` materializes the `federal_benefit_cost` / `state_benefit_cost` aggregates automatically. diff --git a/src/policyengine/tax_benefit_models/us/analysis.py b/src/policyengine/tax_benefit_models/us/analysis.py index 74096ff3..748f5992 100644 --- a/src/policyengine/tax_benefit_models/us/analysis.py +++ b/src/policyengine/tax_benefit_models/us/analysis.py @@ -68,15 +68,16 @@ class BudgetaryImpact(BaseModel): revenue or lower benefit spending); negative means revenue lost or spending incurred. - ``total`` is measured directly from the reform-minus-baseline change in - ``household_tax`` minus the change in ``household_benefits``, so it covers - every tax and benefit the model computes. ``federal`` and ``state`` hold - only the pieces that are cleanly attributable to a level of government - today; ``unattributed`` is the residual (``total - federal - state``) that - carries everything not yet split. ``total`` is a computed field, so - ``federal + state + unattributed`` always equals it by construction. See - :func:`calculate_budgetary_impact` for the exact variables behind each - share. + ``total`` combines the reform-minus-baseline change in ``household_tax`` and + ``household_benefits`` with the change in the government cost of the + shared-funding health programs (Medicaid FMAP, CHIP eFMAP, and Medicare + Savings Programs), which ``household_benefits`` excludes by default. + ``federal`` and ``state`` hold only the pieces that are cleanly + attributable to a level of government today; ``unattributed`` is the + residual (``total - federal - state``) that carries everything not yet + split. ``total`` is a computed field, so ``federal + state + unattributed`` + always equals it by construction. See :func:`calculate_budgetary_impact` + for the exact variables behind each share. """ federal: float = Field( @@ -142,6 +143,30 @@ def configure_budgetary_impact_variables( add_extra_variables(reform_simulation, _BUDGETARY_IMPACT_EXTRA_VARIABLES) +def _include_health_benefits_in_net_income(simulation: Simulation) -> bool: + """Whether ``household_benefits`` already includes Medicaid/CHIP/MSP. + + ``gov.simulation.include_health_benefits_in_net_income`` (default False in + policyengine-us) controls whether the shared-funding health-program values + are folded into ``household_benefits``. When False, those programs are + excluded from ``household_benefits``, so :func:`calculate_budgetary_impact` + adds their government cost from ``federal_benefit_cost`` / + ``state_benefit_cost`` instead. The value is read from the baseline + simulation at its dataset year; a missing parameter or year falls back to + False (the default). + """ + year = getattr(simulation.dataset, "year", None) + if year is None: + return False + try: + parameter = simulation.tax_benefit_model_version.get_parameter( + "gov.simulation.include_health_benefits_in_net_income" + ) + except ValueError: + return False + return bool(parameter._core_param(f"{year}-01-01")) + + def calculate_budgetary_impact( baseline_simulation: Simulation, reform_simulation: Simulation, @@ -153,48 +178,69 @@ def calculate_budgetary_impact( positive means the government is better off (more tax revenue or lower benefit spending). - ``total`` is measured directly from the model's household aggregates:: - - total = Δhousehold_tax - Δhousehold_benefits - - so it captures *every* tax and benefit the model computes, regardless of - whether the affected program can be attributed to a level of government. + ``total`` is the change in ``household_tax`` minus the change in + ``household_benefits``, plus the change in the shared-funding health-program + government cost (Medicaid FMAP, CHIP eFMAP, and Medicare Savings Programs) + read from ``federal_benefit_cost`` / ``state_benefit_cost``. Those health + programs are excluded from ``household_benefits`` by default + (``gov.simulation.include_health_benefits_in_net_income`` is False), so + their cost is added separately; when that parameter is True the values are + already in ``household_benefits`` and the cost is not added again, avoiding + a double count. ``total`` therefore covers taxes and every benefit the + model counts, including shared-funding health-program cost. ``federal`` and ``state`` attribute only the cleanly-assignable pieces:: federal = Δincome_tax + Δemployee_payroll_tax - Δfederal_benefit_cost state = Δstate_income_tax - Δstate_benefit_cost - where ``federal_benefit_cost`` / ``state_benefit_cost`` are the - policyengine-us aggregates that split shared-funding programs (Medicaid - FMAP, CHIP eFMAP) into their federal and state portions. + where ``federal_benefit_cost`` / ``state_benefit_cost`` split the + shared-funding health programs into their federal and state portions. ``unattributed`` is the residual, ``total - federal - state``. It carries - everything not yet split by level of government — 100%-federal programs - such as SSI and SNAP, 100%-state supplements, and any tax outside the - three tax variables above — until finer attribution exists. Because - ``total`` is measured from ``household_tax`` / ``household_benefits`` - rather than summed from the attributed pieces, a reform to an - unattributed program (for example SSI) surfaces in ``total`` and - ``unattributed`` instead of silently reading as zero. + everything not yet split by level of government — 100%-federal programs such + as SSI and SNAP, 100%-state supplements, and any tax outside the three tax + variables above — until finer attribution exists. Because ``total`` is + measured from the household aggregates and health cost rather than summed + from the attributed pieces, a reform to a shared-funding health program + surfaces in ``total`` and the federal/state split (residual zero), while a + reform to an unattributed program such as SSI surfaces in ``total`` and + ``unattributed`` — neither silently reads as zero. ``federal_benefit_cost`` / ``state_benefit_cost`` are not in the default US output; :func:`economic_impact_analysis` calls :func:`configure_budgetary_impact_variables` to materialize them before the simulations run. Callers using this helper directly must do the same. """ - total = _sum_change( + household_tax_change = _sum_change( baseline_simulation, reform_simulation, "household_tax" - ) - _sum_change(baseline_simulation, reform_simulation, "household_benefits") + ) + household_benefits_change = _sum_change( + baseline_simulation, reform_simulation, "household_benefits" + ) + federal_benefit_cost_change = _sum_change( + baseline_simulation, reform_simulation, "federal_benefit_cost" + ) + state_benefit_cost_change = _sum_change( + baseline_simulation, reform_simulation, "state_benefit_cost" + ) + + total = household_tax_change - household_benefits_change + if not _include_health_benefits_in_net_income(baseline_simulation): + # Medicaid/CHIP/MSP are excluded from household_benefits by default, so + # add their government cost; when the parameter is True they are already + # in household_benefits and adding the cost would double-count. + total -= federal_benefit_cost_change + state_benefit_cost_change federal = ( _sum_change(baseline_simulation, reform_simulation, "income_tax") + _sum_change(baseline_simulation, reform_simulation, "employee_payroll_tax") - - _sum_change(baseline_simulation, reform_simulation, "federal_benefit_cost") + - federal_benefit_cost_change + ) + state = ( + _sum_change(baseline_simulation, reform_simulation, "state_income_tax") + - state_benefit_cost_change ) - state = _sum_change( - baseline_simulation, reform_simulation, "state_income_tax" - ) - _sum_change(baseline_simulation, reform_simulation, "state_benefit_cost") unattributed = total - federal - state return BudgetaryImpact(federal=federal, state=state, unattributed=unattributed) diff --git a/tests/test_budgetary_impact.py b/tests/test_budgetary_impact.py index 7244d60b..eefcb927 100644 --- a/tests/test_budgetary_impact.py +++ b/tests/test_budgetary_impact.py @@ -49,10 +49,19 @@ def fake_sum_change(baseline_sim, reform_sim, variable): return fake_sum_change -def _budgetary_impact_from_deltas(deltas: dict[str, float]) -> BudgetaryImpact: - with patch( - "policyengine.tax_benefit_models.us.analysis._sum_change", - side_effect=_fake_sum_change_factory(deltas), +def _budgetary_impact_from_deltas( + deltas: dict[str, float], *, include_health_benefits: bool = False +) -> BudgetaryImpact: + with ( + patch( + "policyengine.tax_benefit_models.us.analysis._sum_change", + side_effect=_fake_sum_change_factory(deltas), + ), + patch( + "policyengine.tax_benefit_models.us.analysis." + "_include_health_benefits_in_net_income", + return_value=include_health_benefits, + ), ): return calculate_budgetary_impact(None, None) @@ -87,8 +96,10 @@ def test_shared_benefit_rollback_splits_federal_and_state(): # Federal share of Medicaid cost drops $90B, state share $10B. "federal_benefit_cost": -90e9, "state_benefit_cost": -10e9, - # household_benefits falls by the full $100B program cost. - "household_benefits": -100e9, + # household_benefits stays 0: Medicaid/CHIP/MSP are excluded from + # household_benefits by default, so a health reform does not move + # it. The cost drop is captured only by the cost aggregates, which + # calculate_budgetary_impact adds into total. } ) @@ -96,6 +107,29 @@ def test_shared_benefit_rollback_splits_federal_and_state(): assert result.federal == 90e9 assert result.state == 10e9 assert result.unattributed == 0 + # total = 0 - 0 - (-90B) - (-10B) = +100B, fully split into fed/state. + assert result.total == 100e9 + + +def test_health_inclusive_config_avoids_double_count(): + """With gov.simulation.include_health_benefits_in_net_income true, + household_benefits already carries Medicaid/CHIP/MSP, so the cost + aggregates must not be subtracted from total a second time.""" + result = _budgetary_impact_from_deltas( + { + "federal_benefit_cost": -90e9, + "state_benefit_cost": -10e9, + # household_benefits carries the full $100B cut because the + # parameter folds the health programs into it. + "household_benefits": -100e9, + }, + include_health_benefits=True, + ) + + assert result.federal == 90e9 + assert result.state == 10e9 + assert result.unattributed == 0 + # total = 0 - (-100B), with no extra cost subtraction. assert result.total == 100e9 @@ -133,9 +167,10 @@ def test_mixed_reform_partitions_with_nonzero_residual(): "state_benefit_cost": -2e9, # household_tax falls by the three tax cuts: 50 + 10 + 20. "household_tax": -80e9, - # household_benefits nets a $7B shared-program cut and an $8B SSI - # rise: -7 + 8 = +1. - "household_benefits": 1e9, + # household_benefits carries only the $8B SSI rise: the $7B + # shared health-program cut is excluded from household_benefits + # by default and enters total via the cost aggregates. + "household_benefits": 8e9, } ) @@ -143,7 +178,7 @@ def test_mixed_reform_partitions_with_nonzero_residual(): assert result.federal == -55e9 # State = -20 - (-2) = -18. assert result.state == -18e9 - # Total = -80 - 1 = -81. + # Total = -80 - 8 - (-5 + -2) = -81. assert result.total == -81e9 # Residual = -81 - (-55) - (-18) = -8, i.e. the $8B SSI spending rise. assert result.unattributed == -8e9 @@ -333,7 +368,8 @@ def test_calculate_budgetary_impact_runs_against_real_output_simulation(tmp_path assert result.federal == -100.0 # state = Δstate_income_tax - Δstate_benefit_cost = -20 - (-5) = -15 assert result.state == -15.0 - # total = Δhousehold_tax - Δhousehold_benefits = -140 - (-15) = -125 - assert result.total == -125.0 - # unattributed = total - federal - state = -125 - (-100) - (-15) = -10 - assert result.unattributed == -10.0 + # total = Δhousehold_tax - Δhousehold_benefits - Δhealth cost + # = -140 - (-15) - (-20 + -5) = -100 + assert result.total == -100.0 + # unattributed = total - federal - state = -100 - (-100) - (-15) = 15 + assert result.unattributed == 15.0