Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/federal-state-budgetary-impact.added.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions examples/us_budgetary_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/policyengine/tax_benefit_models/us/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from policyengine.core import Dataset

from .analysis import (
BudgetaryImpact,
USHouseholdInput,
USHouseholdOutput,
calculate_budgetary_impact,
calculate_household_impact,
economic_impact_analysis,
)
Expand Down Expand Up @@ -46,6 +48,8 @@
"us_model",
"us_latest",
"economic_impact_analysis",
"calculate_budgetary_impact",
"BudgetaryImpact",
"calculate_household_impact",
"USHouseholdInput",
"USHouseholdOutput",
Expand Down
78 changes: 78 additions & 0 deletions src/policyengine/tax_benefit_models/us/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions tests/test_budgetary_impact.py
Original file line number Diff line number Diff line change
@@ -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
Loading