Add federal vs. state budgetary impact to economic_impact_analysis#296
Add federal vs. state budgetary impact to economic_impact_analysis#296
Conversation
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.
vahid-ahmadi
left a comment
There was a problem hiding this comment.
The shape of this is right and the sign convention is consistent with the rest of the codebase, but I think there's a real bug in the federal-tax computation that the unit tests miss.
Blocking: payroll_tax is not a variable in policyengine-us.
Grepping the installed package: there is no class payroll_tax and no parameter-driven payroll_tax aggregate. Only employee_payroll_tax (TaxUnit) and payroll_tax_gross_wages (a base, not an aggregate) exist. This is the same rename that PR #327 just landed on the program-statistics side.
When economic_impact_analysis calls calculate_budgetary_impact → _sum_change(..., "payroll_tax") → ChangeAggregate.run() → get_aggregate_variable(...), that helper (newly added in #327) will raise:
ValueError: ChangeAggregate.variable references missing variable 'payroll_tax' in policyengine_us version X.Y.Z. Did you mean: 'employee_payroll_tax'?
So this PR currently breaks economic_impact_analysis end-to-end on US. Suggest swapping to employee_payroll_tax to match #327.
The reason the test suite passes is that all four tests patch("...analysis._sum_change", side_effect=...) and feed in mocked deltas. The arithmetic is exercised but the variable names never touch policyengine-us. Worth one thin integration test that calls _sum_change against a real (tiny) simulation for each of the four variables — that's all it'd take to catch this and any future rename.
Also worth resolving before merge
- The PE-US pin bump for
federal_benefit_cost/state_benefit_costis called out as TODO in the description but isn't in the diff. If this lands before the pin bumps, the sameget_aggregate_variableerror fires from insidecalculate_budgetary_impactfor any consumer runningeconomic_impact_analysis. Either bump the pin in this PR, or guard the budgetary-impact computation: try the aggregates; if the variables are missing in the installed PE-US, returnBudgetaryImpact(federal=federal_tax_change, state=state_tax_change, total=...)with a logged warning, and makebudgetary_impactOptional[BudgetaryImpact]onPolicyReformAnalysis. Either path keepseconomic_impact_analysisworking across the supported PE-US version range.
Smaller comments
state_income_taxis at TaxUnit level (verified).ChangeAggregate(... aggregate_type=SUM)will weight by tax-unit weights, which is what you want — fine. PR #327 mentionedhousehold_state_income_taxbecause it was specifically materializing per-household output for snapshots; that's a different use case from the national sum here. Keepingstate_income_taxis correct.- Sign convention check: medicaid rollback test expects
+90e9federal whenfederal_benefit_costdrops by 90e9.0 - (-90e9) = +90e9. Consistent with "positive = government saves money" elsewhere. Good. BudgetaryImpact.totalis recomputed at construction (federal + state) — would be slightly cleaner as a@computed_fieldso it can't be inconsistent if someone later constructs the model directly with mismatched fields. Optional.- The example script will inherit whichever bug above hits first. Worth re-running it after the variable swap to confirm the printed numbers move in the expected direction.
Verified correct
- Sign convention matches the rest of the codebase.
- Arithmetic matches all four test cases under the assumed deltas.
- Scope notes are honest about which programs are omitted (100%-federal and 100%-state programs not in the shared-funding aggregates).
__init__.pyexportsBudgetaryImpactandcalculate_budgetary_impactcleanly.
Happy to re-review once payroll_tax → employee_payroll_tax and the PE-US pin / fallback question are resolved.
|
@vahid-ahmadi does this relate to .py's stronger coupling of region and dataset together? |
Summary
Partitions US reform budgetary impact into federal vs. state shares:
BudgetaryImpact(federal/state/total) model added toPolicyReformAnalysiscalculate_budgetary_impact(baseline_sim, reform_sim) -> BudgetaryImpacthelperexamples/us_budgetary_impact.pyCloses #289.
Formulas
Where
federal_benefit_costandstate_benefit_costare the PE-US aggregates from PolicyEngine/policyengine-us#8076 that sum federal/state shares of Medicaid (FMAP) and CHIP (eFMAP). As more programs gain attribution (SNAP OBBBA FY2028, SSI, etc.), the aggregates grow automatically via the parameter-drivenaddslist — no changes needed here.Why here vs. in policyengine-api
The arithmetic is thin sums over microsim outputs. Every consumer — API, notebooks, ad-hoc scripts — needs the same split; putting it behind the
.pypackage means one implementation. The API follow-up (PolicyEngine/policyengine-api#3481) becomes a pass-through.Test plan
pytest tests/test_budgetary_impact.py -v— 4 unit tests pass (federal-tax-cut, Medicaid expansion rollback fed/state split, mixed, zero-reform)policyengine-us >= 1.XXX.Y)Scope notes
household_state_benefits) are not folded in here — this exposes only the shared-funding programs infederal_benefit_cost/state_benefit_cost. Follow-up can addfederal_only_benefit_costandstate_only_benefit_costaggregates if useful; for now users can compute those directly.state_income_taxis fully state (true) andincome_tax+payroll_taxis fully federal (true). Not yet modeled: local income taxes, state-level tax credits that show up under federal variables.Related