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 d49d46d4..555d7eec 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 ProgramStatistics - from .analysis import economic_impact_analysis + from .analysis import ( + BudgetaryImpact, + calculate_budgetary_impact, + economic_impact_analysis, + ) from .datasets import ( PolicyEngineUSDataset, USYearData, @@ -68,6 +72,8 @@ "us_latest", "calculate_household", "economic_impact_analysis", + "calculate_budgetary_impact", + "BudgetaryImpact", "ProgramStatistics", ] else: diff --git a/src/policyengine/tax_benefit_models/us/analysis.py b/src/policyengine/tax_benefit_models/us/analysis.py index 8b3eefc8..61a4a97e 100644 --- a/src/policyengine/tax_benefit_models/us/analysis.py +++ b/src/policyengine/tax_benefit_models/us/analysis.py @@ -9,10 +9,14 @@ from typing import Union import pandas as pd -from pydantic import BaseModel +from pydantic import BaseModel, Field from policyengine.core import OutputCollection, Simulation from policyengine.outputs import ProgramStatistics +from policyengine.outputs.change_aggregate import ( + ChangeAggregate, + ChangeAggregateType, +) from policyengine.outputs.decile_impact import ( DecileImpact, calculate_decile_impacts, @@ -28,11 +32,80 @@ ) +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 @@ -129,9 +202,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