From 5e9695565b41fe113ed8d2d9e80d9ce3f93a0ebd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 19 Apr 2026 08:00:21 -0400 Subject: [PATCH] Partition US budgetary impact into federal and state shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds BudgetaryImpact model with federal/state/total fields, exposed on PolicyReformAnalysis and via a new standalone calculate_budgetary_impact helper. Federal = change in income_tax + payroll_tax minus change in federal_benefit_cost; state = change in state_income_tax minus change in state_benefit_cost. The arithmetic lives here (not in policyengine-api) so every consumer — API, analysis notebooks, ad-hoc scripts — reuses a single implementation. Requires policyengine-us with federal_benefit_cost / state_benefit_cost aggregates (PolicyEngine/policyengine-us#8076). Pin bump is separate. Closes #289. --- .../federal-state-budgetary-impact.added.md | 1 + examples/us_budgetary_impact.py | 6 ++ .../tax_benefit_models/us/__init__.py | 4 + .../tax_benefit_models/us/analysis.py | 78 +++++++++++++++ tests/test_budgetary_impact.py | 97 +++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 changelog.d/federal-state-budgetary-impact.added.md create mode 100644 tests/test_budgetary_impact.py diff --git a/changelog.d/federal-state-budgetary-impact.added.md b/changelog.d/federal-state-budgetary-impact.added.md new file mode 100644 index 00000000..167bc583 --- /dev/null +++ b/changelog.d/federal-state-budgetary-impact.added.md @@ -0,0 +1 @@ +Partition US policy reform budgetary impact into federal vs. state shares via `BudgetaryImpact` on `PolicyReformAnalysis` and the standalone `calculate_budgetary_impact` helper. Federal = change in `income_tax` + `payroll_tax` minus change in `federal_benefit_cost`; state = change in `state_income_tax` minus change in `state_benefit_cost`. Requires `policyengine-us` with the `federal_benefit_cost` / `state_benefit_cost` aggregates (PolicyEngine/policyengine-us#8076). diff --git a/examples/us_budgetary_impact.py b/examples/us_budgetary_impact.py index f99df6d1..52033d00 100644 --- a/examples/us_budgetary_impact.py +++ b/examples/us_budgetary_impact.py @@ -109,6 +109,12 @@ def main(): print("\nRunning full economic impact analysis...") analysis = economic_impact_analysis(baseline_sim, reform_sim) + print("\n=== Budgetary Impact (Federal vs State) ===") + b = analysis.budgetary_impact + print(f" Federal: ${b.federal / 1e9:+8.1f}B") + print(f" State: ${b.state / 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 75d2aa79..5650228a 100644 --- a/src/policyengine/tax_benefit_models/us/__init__.py +++ b/src/policyengine/tax_benefit_models/us/__init__.py @@ -6,8 +6,10 @@ from policyengine.core import Dataset from .analysis import ( + BudgetaryImpact, USHouseholdInput, USHouseholdOutput, + calculate_budgetary_impact, calculate_household_impact, economic_impact_analysis, ) @@ -46,6 +48,8 @@ "us_model", "us_latest", "economic_impact_analysis", + "calculate_budgetary_impact", + "BudgetaryImpact", "calculate_household_impact", "USHouseholdInput", "USHouseholdOutput", diff --git a/src/policyengine/tax_benefit_models/us/analysis.py b/src/policyengine/tax_benefit_models/us/analysis.py index 122ae2af..199dd90f 100644 --- a/src/policyengine/tax_benefit_models/us/analysis.py +++ b/src/policyengine/tax_benefit_models/us/analysis.py @@ -10,6 +10,10 @@ from policyengine.core import OutputCollection, Simulation from policyengine.core.policy import Policy +from policyengine.outputs.change_aggregate import ( + ChangeAggregate, + ChangeAggregateType, +) from policyengine.outputs.decile_impact import ( DecileImpact, calculate_decile_impacts, @@ -187,11 +191,80 @@ def extract_entity_outputs( ) +class BudgetaryImpact(BaseModel): + """Federal/state partition of a reform's budgetary impact. + + Sign convention: a negative value is revenue the government loses + (or spending it incurs). Symmetric to the change in `income_tax`: + reform tax revenue minus baseline tax revenue, with benefit spending + subtracted. + """ + + federal: float = Field(..., description="Federal budgetary impact, USD.") + state: float = Field(..., description="State budgetary impact, USD.") + total: float = Field(..., description="Total budgetary impact, USD.") + + +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) + + +def calculate_budgetary_impact( + baseline_simulation: Simulation, + reform_simulation: Simulation, +) -> BudgetaryImpact: + """Partition a reform's budgetary impact into federal and state shares. + + Federal share = change in federal tax revenue (income_tax + payroll_tax) + minus change in federal benefit spending (`federal_benefit_cost`, which + sums the federal portion of shared-funding benefit programs — currently + Medicaid and CHIP). + + State share = change in state tax revenue (state_income_tax) minus + change in state benefit spending (`state_benefit_cost`). + + Programs that are 100% federal (SNAP benefits pre-FY2028, SSI, LIHEAP, + WIC, Section 8, school meals) and 100% state (state supplements via + `household_state_benefits`) are not yet folded in; this partitions only + the shared-funding programs exposed through + `federal_benefit_cost` / `state_benefit_cost` in policyengine-us. + """ + federal_tax_change = _sum_change( + baseline_simulation, reform_simulation, "income_tax" + ) + _sum_change(baseline_simulation, reform_simulation, "payroll_tax") + state_tax_change = _sum_change( + baseline_simulation, reform_simulation, "state_income_tax" + ) + federal_benefit_change = _sum_change( + baseline_simulation, reform_simulation, "federal_benefit_cost" + ) + state_benefit_change = _sum_change( + baseline_simulation, reform_simulation, "state_benefit_cost" + ) + + federal = federal_tax_change - federal_benefit_change + state = state_tax_change - state_benefit_change + return BudgetaryImpact(federal=federal, state=state, total=federal + state) + + 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 @@ -301,9 +374,14 @@ def economic_impact_analysis( reform_simulation, preset=inequality_preset ) + 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..fbcb735a --- /dev/null +++ b/tests/test_budgetary_impact.py @@ -0,0 +1,97 @@ +"""Unit tests for federal/state budgetary impact partitioning.""" + +from unittest.mock import patch + +from policyengine.tax_benefit_models.us.analysis import ( + BudgetaryImpact, + calculate_budgetary_impact, +) + + +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 test_federal_tax_cut_only(): + """A pure federal tax cut shows up as negative federal impact, zero state.""" + deltas = { + "income_tax": -100e9, + "payroll_tax": 0, + "state_income_tax": 0, + "federal_benefit_cost": 0, + "state_benefit_cost": 0, + } + with patch( + "policyengine.tax_benefit_models.us.analysis._sum_change", + side_effect=_fake_sum_change_factory(deltas), + ): + result = calculate_budgetary_impact(None, None) + + assert isinstance(result, BudgetaryImpact) + assert result.federal == -100e9 + assert result.state == 0 + assert result.total == -100e9 + + +def test_medicaid_expansion_rollback_shifts_cost_to_states(): + """Repealing ACA expansion reduces federal benefit spending 10x more + than state (90% vs 10% FMAP), which shows as a federal *gain* and + a small state gain — sign: reduced spending = positive fiscal impact.""" + deltas = { + "income_tax": 0, + "payroll_tax": 0, + "state_income_tax": 0, + # Federal benefit spending drops by $90B, state by $10B + "federal_benefit_cost": -90e9, + "state_benefit_cost": -10e9, + } + with patch( + "policyengine.tax_benefit_models.us.analysis._sum_change", + side_effect=_fake_sum_change_factory(deltas), + ): + result = calculate_budgetary_impact(None, None) + + # Federal "impact" = -(-90B) = +90B (government saves money) + assert result.federal == 90e9 + assert result.state == 10e9 + assert result.total == 100e9 + + +def test_mixed_federal_and_state_tax_changes(): + """Federal income tax cut + state income tax cut partition correctly.""" + deltas = { + "income_tax": -50e9, + "payroll_tax": -10e9, + "state_income_tax": -20e9, + "federal_benefit_cost": 5e9, + "state_benefit_cost": 2e9, + } + with patch( + "policyengine.tax_benefit_models.us.analysis._sum_change", + side_effect=_fake_sum_change_factory(deltas), + ): + result = calculate_budgetary_impact(None, None) + + # Federal = (-50B + -10B) - 5B = -65B + assert result.federal == -65e9 + # State = -20B - 2B = -22B + assert result.state == -22e9 + assert result.total == -87e9 + + +def test_zero_reform_gives_zero_impact(): + deltas = {} # all zero + with patch( + "policyengine.tax_benefit_models.us.analysis._sum_change", + side_effect=_fake_sum_change_factory(deltas), + ): + result = calculate_budgetary_impact(None, None) + + assert result.federal == 0 + assert result.state == 0 + assert result.total == 0