diff --git a/changelog.d/289.added.md b/changelog.d/289.added.md new file mode 100644 index 00000000..fc7f3855 --- /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` 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/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 bd271214..0465e97e 100644 --- a/src/policyengine/tax_benefit_models/us/analysis.py +++ b/src/policyengine/tax_benefit_models/us/analysis.py @@ -8,7 +8,7 @@ from typing import Union -from pydantic import BaseModel +from pydantic import BaseModel, Field, computed_field from policyengine.core import OutputCollection, Simulation from policyengine.outputs import ( @@ -22,10 +22,15 @@ configure_labor_supply_response_variables, validate_program_statistics_config, ) +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,197 @@ } +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`` 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( + ..., + 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 _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, +) -> 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 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`` 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 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. + """ + household_tax_change = _sum_change( + baseline_simulation, reform_simulation, "household_tax" + ) + 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") + - federal_benefit_cost_change + ) + state = ( + _sum_change(baseline_simulation, reform_simulation, "state_income_tax") + - state_benefit_cost_change + ) + + 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 @@ -106,6 +297,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() @@ -149,9 +341,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..eefcb927 --- /dev/null +++ b/tests/test_budgetary_impact.py @@ -0,0 +1,375 @@ +"""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], *, 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) + + +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 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. + } + ) + + # Federal "impact" = -(-90B) = +90B (government saves money). + 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 + + +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 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, + } + ) + + # Federal = -50 + -10 - (-5) = -55. + assert result.federal == -55e9 + # State = -20 - (-2) = -18. + assert result.state == -18e9 + # 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 + + +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 - Δ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 diff --git a/tests/test_cliff_impact_analysis.py b/tests/test_cliff_impact_analysis.py index bd33ba51..3ebf8200 100644 --- a/tests/test_cliff_impact_analysis.py +++ b/tests/test_cliff_impact_analysis.py @@ -119,6 +119,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