diff --git a/Makefile b/Makefile index 656997cca44..ed4713d9b03 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ REST_SPM_TESTS := $(TESTS)/core/test_spm_policy_family.py \ # from its dry run and spreads their files over parallel runners by recent # CI times, splitting a long group into chunks. A process still runs files # of one group only, in this order, so none holds more than its group here. -REST_PYTHON_GROUPS := cached-arrays heavy-a heavy-b core-heavy ss-calculator-engine ss-calculator-differential ss-history-engine ss-history-differential social-security-inputs core policy-parameters policy remaining-heavy remaining +REST_PYTHON_GROUPS := cached-arrays heavy-a heavy-b core-heavy ss-calculator-engine ss-calculator-differential ss-history-engine ss-history-differential social-security-inputs core policy-parameters policy reform-benefit-lists remaining-heavy remaining REST_CACHED_ARRAY_TESTS := $(TESTS)/test_formulas_do_not_write_into_cached_arrays.py REST_HEAVY_A_TESTS := $(TESTS)/test_md_poverty_line_credit_invariants.py \ $(TESTS)/core/test_ald_determinism.py \ @@ -96,6 +96,11 @@ REST_SOCIAL_SECURITY_PIA_RATIO_INPUT_TESTS := $(TESTS)/core/test_ss_pia_ratio_in # a default Microsimulation only to read parameters. Release their heap before # the remaining policy tests, rather than stacking both in the 13.9 GB group. REST_POLICY_PARAMETER_TESTS := $(TESTS)/policy/baseline/parameters +# test_reform_benefit_lists.py builds a full system for each of four reforms, +# one at a time. Local macOS run on a loaded host: 21 passed in 23.5 minutes +# with 5.7 GiB peak RSS. Its own process keeps that heap apart from the systems +# remaining-heavy retains (10.5 GiB peak RSS in release run 42082143005). +REST_REFORM_BENEFIT_LIST_TESTS := $(TESTS)/test_reform_benefit_lists.py # The first four files retain three independent full systems (the two CTC # files share one). The last two use transient TX simulations and balance # elapsed time without warming the NY/AL pinned-system cache. @@ -109,7 +114,7 @@ REST_LISTED_TESTS := $(REST_CACHED_ARRAY_TESTS) $(REST_HEAVY_A_TESTS) $(REST_HEA $(REST_CORE_HEAVY_TESTS) $(REST_SS_CALCULATOR_ENGINE_TESTS) $(REST_SS_CALCULATOR_DIFFERENTIAL_TESTS) \ $(REST_SS_HISTORY_ENGINE_TESTS) $(REST_SS_HISTORY_DIFFERENTIAL_TESTS) \ $(REST_SOCIAL_SECURITY_INPUT_TESTS) $(REST_SOCIAL_SECURITY_PIA_RATIO_INPUT_TESTS) \ - $(REST_POLICY_PARAMETER_TESTS) $(REST_REMAINING_HEAVY_TESTS) + $(REST_POLICY_PARAMETER_TESTS) $(REST_REFORM_BENEFIT_LIST_TESTS) $(REST_REMAINING_HEAVY_TESTS) REST_IGNORES := $(addprefix --ignore=,$(TESTS)/policy/contrib $(TESTS)/microsimulation $(REST_SPM_TESTS)) # $(call rest_pytest,,,) is the pytest # command for one group. With REST_REPORT_DIR set, each group writes its own @@ -362,6 +367,8 @@ test-other-python-rest-policy-parameters: $(call rest_pytest,policy-parameters,$(REST_POLICY_PARAMETER_TESTS)) test-other-python-rest-policy: $(call rest_pytest,policy,$(TESTS)/policy,$(REST_LISTED_TESTS)) +test-other-python-rest-reform-benefit-lists: + $(call rest_pytest,reform-benefit-lists,$(REST_REFORM_BENEFIT_LIST_TESTS)) test-other-python-rest-remaining-heavy: $(call rest_pytest,remaining-heavy,$(REST_REMAINING_HEAVY_TESTS)) test-other-python-rest-remaining: diff --git a/changelog.d/spm-benefits-parameter.fixed.md b/changelog.d/spm-benefits-parameter.fixed.md new file mode 100644 index 00000000000..42196c5b328 --- /dev/null +++ b/changelog.d/spm-benefits-parameter.fixed.md @@ -0,0 +1 @@ +Keep every baseline benefit in SPM resources under the Texas rebate, the End Child Poverty Act, its Economic Dignity for All Agenda version and the BOOST Act. Each reform copied the `spm_unit_benefits` program list, and the copies had fallen behind: Texas dropped 19 state SSI supplements, and the three Tlaib reforms dropped 42 supplements, Washington's State Family Assistance and Refugee Cash Assistance and the Washington and West Virginia child care subsidies, and counted ACP and EBB twice. The list now lives in the `gov.household.spm_unit_benefits` parameter, and each reform adds its own benefit there and to `gov.household.household_benefits` with the new `add_to_list_parameter` helper. The End Child Poverty Act and BOOST Act household totals now count CSFP and household health benefits too. A new `boost_middle_class_tax_credit_paid_through_ssa` variable holds the BOOST credit when the Social Security Administration pays it. diff --git a/policyengine_us/parameters/gov/household/spm_unit_benefits.yaml b/policyengine_us/parameters/gov/household/spm_unit_benefits.yaml new file mode 100644 index 00000000000..3b0eceada68 --- /dev/null +++ b/policyengine_us/parameters/gov/household/spm_unit_benefits.yaml @@ -0,0 +1,109 @@ +description: PolicyEngine counts these sources as SPM unit benefits. +# Reforms add their own benefits here with add_to_list_parameter +# (policyengine_us/tools/parameters.py) and leave spm_unit_benefits alone. +# spm_unit_net_income adds acp and ebb itself, so they are not listed. The +# spm_unit_benefits formula adds the SPM's capped housing subsidy, and the flat +# tax reform's assigned_aca_ptc under its deduct_ptc switch. +values: + 0000-01-01: + - social_security + - ssi + - in_ssp + - ct_ssp + - ga_ssp + - al_ssp + - ak_ssp + - dc_ossp # DC benefits + - id_aabd # Idaho benefits + - vt_aabd # Vermont benefits + - ky_ssp # Kentucky benefits + - de_ssp # Delaware benefits + - fl_oss + - nj_ssp # New Jersey benefits + - nh_ssp # New Hampshire benefits + - pa_ssp # Pennsylvania benefits + - wy_ssp # Wyoming benefits + - or_ssp # Oregon benefits + - nc_sa # North Carolina benefits + - mt_ssp # Montana benefits + - ok_ssp # Oklahoma benefits + - oh_rss # Ohio benefits + - ny_ssp # New York benefits + - ri_ssp # Rhode Island benefits + - ut_ssp # Utah benefits + - sd_ssp # South Dakota benefits + - va_ag # Virginia benefits + - ia_ssa # Iowa benefits + - ks_sspp # Kansas benefits + - hi_oss + - la_oss # Louisiana benefits + - ma_state_supplement # Massachusetts benefits + - md_paa # Maryland benefits + - wa_ssp # Washington benefits + - wi_ssp # Wisconsin benefits + - mi_ssp # Michigan benefits + - me_ssp # Maine benefits + - mo_ssp # Missouri benefits + - mn_msa # Minnesota benefits + - ne_aabd # Nebraska benefits + - nv_oss # Nevada benefits + # California programs. + - ca_cvrp # California Clean Vehicle Rebate Project. + # California SSI state supplement (SSP). The formula nets federal SSI and + # countable income out of the state payment standard, so summing it with + # ssi does not double-count. + - ca_state_supplement + # Colorado programs. + - co_ccap_subsidy + - co_state_supplement + - co_oap + # Washington programs. + - wa_child_care_subsidies + # New Mexico programs. + - nm_ssi_state_supplement + # South Carolina programs. + - sc_ssi_state_supplement + # Texas programs. + - tx_ssi_state_supplement + # West Virginia programs. + - wv_child_care_subsidies + - snap + - wic + - free_school_meals + - reduced_price_school_meals + - child_support_received + - workers_compensation + - educational_assistance + - financial_assistance + - survivor_benefits + - spm_unit_energy_subsidy + - tanf + # Washington (WA) cash-assistance programs. wa_sfa and wa_rca sit + # alongside the federal TANF aggregator entry; under default rules these + # three are mutually exclusive at the SPM-unit level so summing them does + # not double-count. + - wa_sfa + - wa_rca + - high_efficiency_electric_home_rebate + - residential_efficiency_electrification_rebate + - total_unemployment_compensation + # One-time energy relief payments. + # Paid at the same time as the Alaska Permanent Fund Dividend, which is + # part of IRS gross income. + - ak_energy_relief + # Contributed. + - basic_income + - trump_dividend + # Cash programs that household_benefits also counts. + - ca_capi # California Cash Assistance Program for Immigrants. + - ca_la_expectant_parent_payment # Los Angeles County. + - ca_la_infant_supplement # Los Angeles County. + - co_denver_property_tax_relief # Denver. + - ma_eaedc # Massachusetts. + - nj_property_tax_relief # ANCHOR, Senior Freeze and Stay NJ. + - ny_drive_clean_rebate + +metadata: + unit: list + period: year + label: SPM unit benefits diff --git a/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py index d4a2face50a..f70c898c1a8 100644 --- a/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py +++ b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.parameters import add_to_list_parameter def create_boost_middle_class_tax_credit() -> Reform: @@ -43,127 +44,39 @@ def formula(tax_unit, period, parameters): return previous_credits return middle_class_credit + previous_credits - class household_benefits(Variable): + class boost_middle_class_tax_credit_paid_through_ssa(Variable): value_type = float - entity = Household - label = "benefits" + entity = TaxUnit + label = "BOOST Middle Class Tax Credit paid through SSA" unit = USD definition_period = YEAR + documentation = ( + "The credit as a benefit when the Social Security Administration " + "pays it, and zero when it is a refundable income tax credit." + ) - def formula(household, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - # Broadband subsidies. - "acp", - "ebb", - "tanf", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - # Contributed. - "basic_income", - "trump_dividend", - "housing_assistance", - "household_state_benefits", - "household_local_benefits", - "household_head_start_benefits", - "ak_energy_relief", - "ny_drive_clean_rebate", - ] - if parameters(period).gov.hud.abolition: - BENEFITS = [ - benefit for benefit in BENEFITS if benefit != "housing_assistance" - ] - previous_benefits = add(household, period, BENEFITS) - middle_class_credit = add( - household, period, ["boost_middle_class_tax_credit"] - ) - p = parameters( - period - ).gov.contrib.congress.tlaib.boost.middle_class_tax_credit - if p.administered_through_ssa is False: - return previous_benefits - return middle_class_credit + previous_benefits - - class spm_unit_benefits(Variable): - value_type = float - entity = SPMUnit - label = "Benefits" - definition_period = YEAR - unit = USD - - def formula(spm_unit, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "ma_state_supplement", # Massachusetts benefits - # California programs. - "ca_cvrp", # California Clean Vehicle Rebate Project. - # Colorado programs. - "co_ccap_subsidy", - "co_state_supplement", - "co_oap", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - # Broadband subsidies. - "acp", - "ebb", - "spm_unit_energy_subsidy", - "tanf", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - # Contributed. - "basic_income", - "trump_dividend", - # Cash programs that household_benefits also counts. - "ca_capi", # California Cash Assistance Program for Immigrants. - "ca_la_expectant_parent_payment", # Los Angeles County. - "ca_la_infant_supplement", # Los Angeles County. - "co_denver_property_tax_relief", # Denver. - "ma_eaedc", # Massachusetts. - "ak_energy_relief", - "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. - "ny_drive_clean_rebate", - ] - if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: - BENEFITS.append("aca_ptc") - if not parameters(period).gov.hud.abolition: - BENEFITS.append("spm_unit_capped_housing_subsidy") + def formula(tax_unit, period, parameters): p = parameters( period ).gov.contrib.congress.tlaib.boost.middle_class_tax_credit - middle_class_tax_credit = add( - spm_unit, period, ["boost_middle_class_tax_credit"] - ) - previous_benefits = add(spm_unit, period, BENEFITS) - if p.administered_through_ssa is False: - return previous_benefits - return add(spm_unit, period, BENEFITS) + middle_class_tax_credit + credit = tax_unit("boost_middle_class_tax_credit", period) + return credit * p.administered_through_ssa + + def modify_parameters(parameters): + # Paid through SSA, the credit is a benefit in both income totals; + # otherwise income_tax_refundable_credits counts it. + household = parameters.gov.household + paid_through_ssa = ["boost_middle_class_tax_credit_paid_through_ssa"] + add_to_list_parameter(household.household_benefits, paid_through_ssa) + add_to_list_parameter(household.spm_unit_benefits, paid_through_ssa) + return parameters class reform(Reform): def apply(self): self.update_variable(boost_middle_class_tax_credit) + self.update_variable(boost_middle_class_tax_credit_paid_through_ssa) self.update_variable(income_tax_refundable_credits) - self.update_variable(household_benefits) - self.update_variable(spm_unit_benefits) + self.modify_parameters(modify_parameters) return reform diff --git a/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py b/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py index 4b6552e52e3..b0106025e45 100644 --- a/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.parameters import add_to_list_parameter def create_ecpa_only() -> Reform: @@ -86,81 +87,13 @@ def formula(tax_unit, period, parameters): return has_eligible_filer * phased_out_amount - class household_benefits(Variable): - value_type = float - entity = Household - label = "benefits" - unit = USD - definition_period = YEAR - - def formula(household, period, parameters): - # Start with baseline benefits from parameters - BENEFITS = list(parameters(period).gov.household.household_benefits) - if parameters(period).gov.hud.abolition: - BENEFITS = [ - benefit for benefit in BENEFITS if benefit != "housing_assistance" - ] - - # Add ECPA child benefit - BENEFITS.append("ecpa_child_benefit") - - return add(household, period, BENEFITS) - - class spm_unit_benefits(Variable): - value_type = float - entity = SPMUnit - label = "Benefits" - definition_period = YEAR - unit = USD - - def formula(spm_unit, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "ma_state_supplement", - "ca_cvrp", - "co_ccap_subsidy", - "co_state_supplement", - "co_oap", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - # Broadband subsidies. - "acp", - "ebb", - "spm_unit_energy_subsidy", - "tanf", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - "ak_energy_relief", - "basic_income", - "trump_dividend", - # Cash programs that household_benefits also counts. - "ca_capi", # California Cash Assistance Program for Immigrants. - "ca_la_expectant_parent_payment", # Los Angeles County. - "ca_la_infant_supplement", # Los Angeles County. - "co_denver_property_tax_relief", # Denver. - "ma_eaedc", # Massachusetts. - "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. - "ny_drive_clean_rebate", - ] - - # Add ECPA child benefit - BENEFITS.append("ecpa_child_benefit") - - if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: - BENEFITS.append("aca_ptc") - if not parameters(period).gov.hud.abolition: - BENEFITS.append("spm_unit_capped_housing_subsidy") - - return add(spm_unit, period, BENEFITS) + def modify_parameters(parameters): + # The child benefit is a payment, so both income totals count it on + # top of every baseline benefit. + household = parameters.gov.household + add_to_list_parameter(household.household_benefits, ["ecpa_child_benefit"]) + add_to_list_parameter(household.spm_unit_benefits, ["ecpa_child_benefit"]) + return parameters class income_tax_refundable_credits(Variable): value_type = float @@ -207,10 +140,9 @@ def apply(self): self.update_variable(ecpa_child_benefit) self.update_variable(ecpa_adult_dependent_credit) self.update_variable(ecpa_filer_credit) - self.update_variable(household_benefits) - self.update_variable(spm_unit_benefits) self.update_variable(income_tax_refundable_credits) self.update_variable(income_tax_non_refundable_credits) + self.modify_parameters(modify_parameters) return reform diff --git a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py index 7dea7b0defc..9abff82bba0 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.parameters import add_to_list_parameter def create_end_child_poverty_act() -> Reform: @@ -79,104 +80,13 @@ def formula(tax_unit, period, parameters): return total_dependents * amount - class household_benefits(Variable): - value_type = float - entity = Household - label = "benefits" - unit = USD - definition_period = YEAR - - def formula(household, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - # Broadband subsidies. - "acp", - "ebb", - "tanf", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - # Contributed. - "basic_income", - "trump_dividend", - "housing_assistance", - "household_state_benefits", - "household_local_benefits", - "household_head_start_benefits", - "ak_energy_relief", - "ny_drive_clean_rebate", - "ecpa_child_benefit", - ] - if parameters(period).gov.hud.abolition: - BENEFITS = [ - benefit for benefit in BENEFITS if benefit != "housing_assistance" - ] - return add(household, period, BENEFITS) - - class spm_unit_benefits(Variable): - value_type = float - entity = SPMUnit - label = "Benefits" - definition_period = YEAR - unit = USD - - def formula(spm_unit, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "ma_state_supplement", # Massachusetts benefits - # California programs. - "ca_cvrp", # California Clean Vehicle Rebate Project. - # Colorado programs. - "co_ccap_subsidy", - "co_state_supplement", - "co_oap", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - # Broadband subsidies. - "acp", - "ebb", - "spm_unit_energy_subsidy", - "tanf", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - # Contributed. - "basic_income", - "trump_dividend", - # Cash programs that household_benefits also counts. - "ca_capi", # California Cash Assistance Program for Immigrants. - "ca_la_expectant_parent_payment", # Los Angeles County. - "ca_la_infant_supplement", # Los Angeles County. - "co_denver_property_tax_relief", # Denver. - "ma_eaedc", # Massachusetts. - "ak_energy_relief", - "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. - "ny_drive_clean_rebate", - "ecpa_child_benefit", - ] - if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: - BENEFITS.append("aca_ptc") - if not parameters(period).gov.hud.abolition: - BENEFITS.append("spm_unit_capped_housing_subsidy") - return add(spm_unit, period, BENEFITS) + def modify_parameters(parameters): + # The child benefit is a payment, so both income totals count it on + # top of every baseline benefit. + household = parameters.gov.household + add_to_list_parameter(household.household_benefits, ["ecpa_child_benefit"]) + add_to_list_parameter(household.spm_unit_benefits, ["ecpa_child_benefit"]) + return parameters class income_tax_refundable_credits(Variable): value_type = float @@ -198,8 +108,7 @@ def apply(self): self.update_variable(ecpa_filer_credit) self.update_variable(income_tax_refundable_credits) self.update_variable(ecpa_child_benefit) - self.update_variable(household_benefits) - self.update_variable(spm_unit_benefits) + self.modify_parameters(modify_parameters) self.neutralize_variable("eitc") self.neutralize_variable("ctc") diff --git a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py index 3ae570a9999..ceeaae88ab5 100644 --- a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py +++ b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py @@ -15,6 +15,7 @@ from policyengine_us.model_api import * from policyengine_core.periods import period as period_ from policyengine_core.periods import instant +from policyengine_us.tools.parameters import add_to_list_parameter def create_tx_rebate() -> Reform: @@ -40,103 +41,19 @@ def modify_parameters(parameters): stop=instant("2027-12-31"), value=new_benefits, ) + # CountryTaxBenefitSystem clears every modified flag below the root + # before it applies a reform it is given for the second time, and the + # check above then skips the update, so mark the list here: the macro + # cache reads the flag as "differs from current law". + state_benefits.mark_as_modified() + # SPM resources count the rebate as a benefit. It is zero outside + # 2027, so the list carries it in every year. + add_to_list_parameter(parameters.gov.household.spm_unit_benefits, ["tx_rebate"]) return parameters - class spm_unit_benefits(Variable): - value_type = float - entity = SPMUnit - label = "Benefits" - definition_period = YEAR - unit = USD - - def formula(spm_unit, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "in_ssp", - "ct_ssp", - "ga_ssp", - "al_ssp", - "ak_ssp", - "dc_ossp", # DC benefits - "id_aabd", # Idaho benefits - "ky_ssp", # Kentucky benefits - "de_ssp", # Delaware benefits - "fl_oss", - "ks_sspp", # Kansas benefits - "hi_oss", - "la_oss", # Louisiana benefits - "ma_state_supplement", # Massachusetts benefits - "md_paa", # Maryland benefits - "wa_ssp", # Washington benefits - "mi_ssp", # Michigan benefits - "me_ssp", # Maine benefits - "mo_ssp", # Missouri benefits - "mn_msa", # Minnesota benefits - "ne_aabd", # Nebraska benefits - # California programs. - "ca_cvrp", # California Clean Vehicle Rebate Project. - # Colorado programs. - "co_ccap_subsidy", - "co_state_supplement", - "co_oap", - # Washington programs. - "wa_child_care_subsidies", - # New Mexico programs. - "nm_ssi_state_supplement", - # South Carolina programs. - "sc_ssi_state_supplement", - # Texas programs. - "tx_ssi_state_supplement", - # West Virginia programs. - "wv_child_care_subsidies", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - "spm_unit_energy_subsidy", - "tanf", - # Washington (WA) cash-assistance programs. wa_sfa and wa_rca - # sit alongside the federal TANF aggregator entry; under default - # rules these three are mutually exclusive at the SPM-unit level - # so summing them does not double-count. - "wa_sfa", - "wa_rca", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - # One-time energy relief payments. - # Paid at the same time as the Alaska Permanent Fund Dividend, - # which is part of IRS gross income. - "ak_energy_relief", - # Contributed. - "basic_income", - "trump_dividend", - # Cash programs that household_benefits also counts. - "ca_capi", # California Cash Assistance Program for Immigrants. - "ca_la_expectant_parent_payment", # Los Angeles County. - "ca_la_infant_supplement", # Los Angeles County. - "co_denver_property_tax_relief", # Denver. - "ma_eaedc", # Massachusetts. - "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. - "ny_drive_clean_rebate", - "tx_rebate", - ] - if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: - BENEFITS.append("assigned_aca_ptc") - if not parameters(period).gov.hud.abolition: - BENEFITS.append("spm_unit_capped_housing_subsidy") - return add(spm_unit, period, BENEFITS) - class reform(Reform): def apply(self): self.update_variable(tx_rebate) - self.update_variable(spm_unit_benefits) self.modify_parameters(modify_parameters) return reform diff --git a/policyengine_us/tests/policy/reform/ak_energy_relief_in_reform_benefit_aggregates.yaml b/policyengine_us/tests/policy/reform/ak_energy_relief_in_reform_benefit_aggregates.yaml index e95a7ed8089..1ff5baa896d 100644 --- a/policyengine_us/tests/policy/reform/ak_energy_relief_in_reform_benefit_aggregates.yaml +++ b/policyengine_us/tests/policy/reform/ak_energy_relief_in_reform_benefit_aggregates.yaml @@ -1,5 +1,5 @@ # Alaska's energy relief must reach both benefit aggregates under the reforms -# that replace household_benefits and spm_unit_benefits with their own lists. +# that add their own benefits to the household and SPM benefit lists. # Two adults at $500,000 of employment income so no other benefit fires and each # aggregate isolates the $298.17 a person paid in 2024. # diff --git a/policyengine_us/tests/policy/reform/boost_counts_every_baseline_benefit.yaml b/policyengine_us/tests/policy/reform/boost_counts_every_baseline_benefit.yaml new file mode 100644 index 00000000000..255306207a6 --- /dev/null +++ b/policyengine_us/tests/policy/reform/boost_counts_every_baseline_benefit.yaml @@ -0,0 +1,119 @@ +# The BOOST Act once replaced spm_unit_benefits with its own copy of the +# program list (#10226). The copy lacked 42 state SSI supplements, Washington's +# State Family Assistance and Refugee Cash Assistance, and the Washington and +# West Virginia child care subsidies. It also listed acp and ebb, which +# spm_unit_net_income adds itself, so both counted twice. Its copy of +# household_benefits lacked CSFP and household_health_benefits. +# +# The reform now adds boost_middle_class_tax_credit_paid_through_ssa to the +# gov.household benefit lists, so every baseline program counts once. The cases +# pay the credit through the Social Security Administration, which makes it a +# benefit, and at $500,000 of employment income it is zero. With one adult at +# that income, no means-tested benefit fires and each total isolates the +# program under test. +# +# This lives under tests/policy/reform (one subprocess per file) rather than +# contrib/congress/tlaib because the congress structural batch runs each +# proposal directory in a single process at its memory ceiling. + +- name: Under BOOST, Washington's SSI supplement and State Family Assistance count in both benefit totals. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 40 + employment_income: 500_000 + wa_ssp: 300 + wa_sfa: 1_000 + state_code: WA + output: + spm_unit_benefits: 1_300 + household_benefits: 1_300 + +- name: Under BOOST, New York's SSI supplement counts in both benefit totals. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 70 + employment_income: 500_000 + ny_ssp: 1_052 + state_code: NY + output: + spm_unit_benefits: 1_052 + household_benefits: 1_052 + +- name: Under BOOST, West Virginia's child care subsidy counts in SPM benefits. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 40 + employment_income: 500_000 + wv_child_care_subsidies: 3_000 + state_code: WV + output: + spm_unit_benefits: 3_000 + +- name: Under BOOST, the Affordable Connectivity Program counts once in SPM net income. + period: 2023 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 40 + employment_income: 500_000 + acp: 360 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + state_code: NY + output: + spm_unit_benefits: 0 + spm_unit_net_income: 500_360 + +- name: Under BOOST, the Emergency Broadband Benefit counts once in SPM net income. + period: 2021 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 40 + employment_income: 500_000 + ebb: 120 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + state_code: NY + output: + spm_unit_benefits: 0 + spm_unit_net_income: 500_120 + +- name: Under BOOST, the Commodity Supplemental Food Program counts in household benefits. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 65 + employment_income: 500_000 + commodity_supplemental_food_program: 600 + state_code: NY + output: + household_benefits: 600 + +- name: Under BOOST, health benefits count in household benefits when the switch is on. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + gov.contrib.harris.lift.middle_class_tax_credit.in_effect: true + gov.contrib.congress.tlaib.boost.middle_class_tax_credit.administered_through_ssa: true + age: 40 + employment_income: 500_000 + gov.simulation.include_health_benefits_in_net_income: true + chip: 2_000 + state_code: NY + output: + household_health_benefits: 2_000 + household_benefits: 2_000 diff --git a/policyengine_us/tests/policy/reform/boost_head_start_benefits_composition.yaml b/policyengine_us/tests/policy/reform/boost_head_start_benefits_composition.yaml index 9f552ed0674..091ddc7c150 100644 --- a/policyengine_us/tests/policy/reform/boost_head_start_benefits_composition.yaml +++ b/policyengine_us/tests/policy/reform/boost_head_start_benefits_composition.yaml @@ -1,12 +1,12 @@ -# Head Start values compose into BOOST's static household_benefits list when +# Head Start values compose into household_benefits under BOOST when # gov.simulation.include_head_start_benefits_in_net_income is on. # # Lives under tests/policy/reform (one subprocess per file) rather than # contrib/congress/tlaib for the same reason as the ECPA composition case: the # congress structural batch runs each proposal directory in a single process at # its memory ceiling, and an extra reformed-system variant stalls that job. -# With administered_through_ssa false, BOOST's household_benefits is the static -# list alone, so the asserted total isolates the wrapper entry. +# With administered_through_ssa false, BOOST adds nothing to household_benefits, +# so the asserted total isolates the wrapper entry. - name: Head Start values compose into BOOST household benefits when the inclusion switch is on. period: 2023 diff --git a/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml b/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml index e64a68262af..845553ed176 100644 --- a/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml +++ b/policyengine_us/tests/policy/reform/ecpa_head_start_benefits_composition.yaml @@ -1,4 +1,4 @@ -# Head Start values compose into ECPA's static household_benefits list when +# Head Start values compose into household_benefits under ECPA when # gov.simulation.include_head_start_benefits_in_net_income is on. # # NOTE: This lives under tests/policy/reform (one subprocess per file) rather diff --git a/policyengine_us/tests/policy/reform/edaa_end_child_poverty_act_counts_every_baseline_benefit.yaml b/policyengine_us/tests/policy/reform/edaa_end_child_poverty_act_counts_every_baseline_benefit.yaml new file mode 100644 index 00000000000..cc04d109dd2 --- /dev/null +++ b/policyengine_us/tests/policy/reform/edaa_end_child_poverty_act_counts_every_baseline_benefit.yaml @@ -0,0 +1,111 @@ +# The Economic Dignity for All Agenda's End Child Poverty Act once replaced +# spm_unit_benefits with its own copy of the program list (#10226). The copy +# lacked 42 state SSI supplements, Washington's State Family Assistance and +# Refugee Cash Assistance, and the Washington and West Virginia child care +# subsidies. It also listed acp and ebb, which spm_unit_net_income adds itself, +# so both counted twice. Its household_benefits already read the +# gov.household.household_benefits parameter. +# +# The reform now adds ecpa_child_benefit to the gov.household benefit lists, so +# every baseline program counts once. One adult at $500,000 of employment +# income, so no means-tested benefit fires and each total isolates the program +# under test. +# +# This lives under tests/policy/reform (one subprocess per file) rather than +# contrib/congress/tlaib because the congress structural batch runs each +# proposal directory in a single process at its memory ceiling. + +- name: Under EDAA, Washington's SSI supplement and State Family Assistance count in both benefit totals. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + wa_ssp: 300 + wa_sfa: 1_000 + state_code: WA + output: + spm_unit_benefits: 1_300 + household_benefits: 1_300 + +- name: Under EDAA, New York's SSI supplement counts in both benefit totals. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 70 + employment_income: 500_000 + ny_ssp: 1_052 + state_code: NY + output: + spm_unit_benefits: 1_052 + household_benefits: 1_052 + +- name: Under EDAA, West Virginia's child care subsidy counts in SPM benefits. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + wv_child_care_subsidies: 3_000 + state_code: WV + output: + spm_unit_benefits: 3_000 + +- name: Under EDAA, the Affordable Connectivity Program counts once in SPM net income. + period: 2023 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + acp: 360 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + state_code: NY + output: + spm_unit_benefits: 0 + spm_unit_net_income: 500_360 + +- name: Under EDAA, the Emergency Broadband Benefit counts once in SPM net income. + period: 2021 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + ebb: 120 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + state_code: NY + output: + spm_unit_benefits: 0 + spm_unit_net_income: 500_120 + +- name: Under EDAA, the Commodity Supplemental Food Program counts in household benefits. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 65 + employment_income: 500_000 + commodity_supplemental_food_program: 600 + state_code: NY + output: + household_benefits: 600 + +- name: Under EDAA, health benefits count in household benefits when the switch is on. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + gov.contrib.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + gov.simulation.include_health_benefits_in_net_income: true + chip: 2_000 + state_code: NY + output: + household_health_benefits: 2_000 + household_benefits: 2_000 diff --git a/policyengine_us/tests/policy/reform/end_child_poverty_act_counts_every_baseline_benefit.yaml b/policyengine_us/tests/policy/reform/end_child_poverty_act_counts_every_baseline_benefit.yaml new file mode 100644 index 00000000000..d64ec30247d --- /dev/null +++ b/policyengine_us/tests/policy/reform/end_child_poverty_act_counts_every_baseline_benefit.yaml @@ -0,0 +1,110 @@ +# The End Child Poverty Act once replaced spm_unit_benefits with its own copy +# of the program list (#10226). The copy lacked 42 state SSI supplements, +# Washington's State Family Assistance and Refugee Cash Assistance, and the +# Washington and West Virginia child care subsidies. It also listed acp and +# ebb, which spm_unit_net_income adds itself, so both counted twice. Its copy +# of household_benefits lacked CSFP and household_health_benefits. +# +# The reform now adds ecpa_child_benefit to the gov.household benefit lists, so +# every baseline program counts once. One adult at $500,000 of employment +# income, so no means-tested benefit fires and each total isolates the program +# under test. +# +# This lives under tests/policy/reform (one subprocess per file) rather than +# contrib/congress/tlaib because the congress structural batch runs each +# proposal directory in a single process at its memory ceiling. + +- name: Under ECPA, Washington's SSI supplement and State Family Assistance count in both benefit totals. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + wa_ssp: 300 + wa_sfa: 1_000 + state_code: WA + output: + spm_unit_benefits: 1_300 + household_benefits: 1_300 + +- name: Under ECPA, New York's SSI supplement counts in both benefit totals. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 70 + employment_income: 500_000 + ny_ssp: 1_052 + state_code: NY + output: + spm_unit_benefits: 1_052 + household_benefits: 1_052 + +- name: Under ECPA, West Virginia's child care subsidy counts in SPM benefits. + period: 2026 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + wv_child_care_subsidies: 3_000 + state_code: WV + output: + spm_unit_benefits: 3_000 + +- name: Under ECPA, the Affordable Connectivity Program counts once in SPM net income. + period: 2023 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + acp: 360 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + state_code: NY + output: + spm_unit_benefits: 0 + spm_unit_net_income: 500_360 + +- name: Under ECPA, the Emergency Broadband Benefit counts once in SPM net income. + period: 2021 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + ebb: 120 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + state_code: NY + output: + spm_unit_benefits: 0 + spm_unit_net_income: 500_120 + +- name: Under ECPA, the Commodity Supplemental Food Program counts in household benefits. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 65 + employment_income: 500_000 + commodity_supplemental_food_program: 600 + state_code: NY + output: + household_benefits: 600 + +- name: Under ECPA, health benefits count in household benefits when the switch is on. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + gov.contrib.congress.tlaib.end_child_poverty_act.in_effect: true + age: 40 + employment_income: 500_000 + gov.simulation.include_health_benefits_in_net_income: true + chip: 2_000 + state_code: NY + output: + household_health_benefits: 2_000 + household_benefits: 2_000 diff --git a/policyengine_us/tests/policy/reform/trump_dividend_in_boost_benefit_aggregates.yaml b/policyengine_us/tests/policy/reform/trump_dividend_in_boost_benefit_aggregates.yaml index c52ea719852..8321d9dca0c 100644 --- a/policyengine_us/tests/policy/reform/trump_dividend_in_boost_benefit_aggregates.yaml +++ b/policyengine_us/tests/policy/reform/trump_dividend_in_boost_benefit_aggregates.yaml @@ -1,8 +1,9 @@ -# The Trump dividend must survive stacking with BOOST, which replaces -# household_benefits and spm_unit_benefits with its own static lists. With -# administered_through_ssa false, both BOOST aggregates are the static list -# alone, so the asserted totals isolate the dividend. Two adult citizens at -# $500,000 of employment income so no other benefit fires. +# The Trump dividend must survive stacking with BOOST. BOOST adds its +# SSA-paid credit to the gov.household benefit lists that both aggregates +# read, so the baseline programs, the dividend among them, stay counted. With +# administered_through_ssa false that credit is zero, so the asserted totals +# isolate the dividend. Two adult citizens at $500,000 of employment income so +# no other benefit fires. # # NOTE: This lives under tests/policy/reform (one subprocess per file) rather # than contrib/congress/tlaib because the congress structural batch runs each diff --git a/policyengine_us/tests/policy/reform/trump_dividend_in_ecpa_benefit_aggregates.yaml b/policyengine_us/tests/policy/reform/trump_dividend_in_ecpa_benefit_aggregates.yaml index 76b4812e0c9..3aa3e2dea3e 100644 --- a/policyengine_us/tests/policy/reform/trump_dividend_in_ecpa_benefit_aggregates.yaml +++ b/policyengine_us/tests/policy/reform/trump_dividend_in_ecpa_benefit_aggregates.yaml @@ -1,7 +1,7 @@ # The Trump dividend must survive stacking with the End Child Poverty Act, -# which replaces household_benefits and spm_unit_benefits with its own static -# lists. Two adult citizens at $500,000 of employment income so no other -# benefit fires and each aggregate isolates the dividend. +# which adds its child benefit to the gov.household benefit lists that both +# aggregates read. Two adult citizens at $500,000 of employment income so no +# other benefit fires and each aggregate isolates the dividend. # # NOTE: This lives under tests/policy/reform (one subprocess per file) rather # than contrib/congress/tlaib because the congress structural batch runs each diff --git a/policyengine_us/tests/policy/reform/trump_dividend_in_edaa_benefit_aggregates.yaml b/policyengine_us/tests/policy/reform/trump_dividend_in_edaa_benefit_aggregates.yaml index 7629d11e5f4..21f9028f492 100644 --- a/policyengine_us/tests/policy/reform/trump_dividend_in_edaa_benefit_aggregates.yaml +++ b/policyengine_us/tests/policy/reform/trump_dividend_in_edaa_benefit_aggregates.yaml @@ -1,9 +1,8 @@ # The Trump dividend must survive stacking with the Economic Dignity for All -# Agenda version of the End Child Poverty Act. That reform rebuilds -# household_benefits from the gov.household.household_benefits parameter but -# keeps a static spm_unit_benefits list, so only the SPM side could drop the -# dividend. Two adult citizens at $500,000 of employment income so no other -# benefit fires and each aggregate isolates the dividend. +# Agenda version of the End Child Poverty Act, which adds its child benefit to +# the gov.household benefit lists that both aggregates read. Two adult +# citizens at $500,000 of employment income so no other benefit fires and each +# aggregate isolates the dividend. # # NOTE: This lives under tests/policy/reform (one subprocess per file) rather # than contrib/congress/tlaib because the congress structural batch runs each diff --git a/policyengine_us/tests/policy/reform/trump_dividend_in_tx_rebate_benefit_aggregates.yaml b/policyengine_us/tests/policy/reform/trump_dividend_in_tx_rebate_benefit_aggregates.yaml index 35a7dff884b..ffc20c6b7b6 100644 --- a/policyengine_us/tests/policy/reform/trump_dividend_in_tx_rebate_benefit_aggregates.yaml +++ b/policyengine_us/tests/policy/reform/trump_dividend_in_tx_rebate_benefit_aggregates.yaml @@ -1,11 +1,9 @@ -# The Trump dividend must survive stacking with the Texas rebate, which -# replaces spm_unit_benefits with its own static list. The rebate itself is -# $1,500 per Texas household, and the reform also appends tx_rebate to the -# gov.household.household_state_benefits parameter, so both aggregates are the -# rebate plus the two $5,000 dividends. household_benefits is not replaced by -# this reform and so still reads the gov.household.household_benefits -# parameter list, which already carries trump_dividend. Two adult citizens at -# $500,000 of employment income so no other benefit fires. +# The Trump dividend must survive stacking with the Texas rebate. The rebate +# is $1,500 per Texas household, and the reform appends tx_rebate to the +# gov.household.spm_unit_benefits and household_state_benefits parameters, so +# both aggregates are the rebate plus the two $5,000 dividends. Both read the +# baseline parameter lists, which already carry trump_dividend. Two adult +# citizens at $500,000 of employment income so no other benefit fires. # # NOTE: This lives under tests/policy/reform (one subprocess per file) rather # than contrib/states/tx because the contrib states batches pack whole state diff --git a/policyengine_us/tests/policy/reform/tx_rebate_counts_every_baseline_benefit.yaml b/policyengine_us/tests/policy/reform/tx_rebate_counts_every_baseline_benefit.yaml new file mode 100644 index 00000000000..c947005013f --- /dev/null +++ b/policyengine_us/tests/policy/reform/tx_rebate_counts_every_baseline_benefit.yaml @@ -0,0 +1,53 @@ +# The Texas rebate once replaced spm_unit_benefits with its own copy of the +# program list. The copy lacked 19 state SSI supplements, and the reform +# applies in every state, so those supplements dropped out of SPM resources +# nationwide (#10226). The reform now adds tx_rebate to the +# gov.household.spm_unit_benefits parameter, and every baseline program still +# counts. One adult at $500,000 of employment income, so no other benefit fires +# and each total isolates the supplement. +# +# This lives under tests/policy/reform (one subprocess per file) rather than +# contrib/states/tx because the contrib states batches pack whole state folders +# into one process at their memory ceiling. + +- name: Under the Texas rebate, Wyoming's SSI supplement counts in both benefit totals. + period: 2027 + reforms: policyengine_us.reforms.states.tx.rebate.tx_rebate.tx_rebate + input: + gov.contrib.states.tx.rebate.in_effect: true + age: 70 + employment_income: 500_000 + wy_ssp: 240 + state_code: WY + output: + tx_rebate: 0 + spm_unit_benefits: 240 + household_benefits: 240 + +- name: Under the Texas rebate, California's SSI supplement counts in both benefit totals. + period: 2027 + reforms: policyengine_us.reforms.states.tx.rebate.tx_rebate.tx_rebate + input: + gov.contrib.states.tx.rebate.in_effect: true + age: 70 + employment_income: 500_000 + ca_state_supplement: 2_000 + state_code: CA + output: + tx_rebate: 0 + spm_unit_benefits: 2_000 + household_benefits: 2_000 + +- name: In Texas, the rebate adds to the baseline benefits rather than replacing them. + period: 2027 + reforms: policyengine_us.reforms.states.tx.rebate.tx_rebate.tx_rebate + input: + gov.contrib.states.tx.rebate.in_effect: true + age: 70 + employment_income: 500_000 + tx_ssi_state_supplement: 300 + state_code: TX + output: + tx_rebate: 1_500 + spm_unit_benefits: 1_800 + household_benefits: 1_800 diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index e76e164a2f0..ca1362e52c0 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -9,11 +9,13 @@ 3. A new year block silently dropping members of the previous block, since these lists are full-replacement rather than incremental. -The second half covers the benefit aggregates themselves. Several structural -reforms replace household_benefits or spm_unit_benefits with their own -hardcoded list, so a contributed program added to the baseline aggregates is -silently dropped under those reforms unless it is added to every copy. That -shipped with the Trump dividend, which #9430 added to the baseline lists only. +The second half covers the benefit aggregates themselves. Structural reforms +used to replace household_benefits or spm_unit_benefits with their own +hardcoded copy of the list, and every copy fell behind: the Trump dividend +(#9430) and dozens of state SSI supplements reached the baseline lists only, so +those reforms dropped them for every household (#10226). Both lists are now +parameters, and a reform adds its own programs to them with +add_to_list_parameter. These tests keep it that way. The last test keeps reforms from copying the household tax totals at all. The abolish-federal-income-tax and abolish-payroll-tax reforms once did, from a @@ -155,10 +157,15 @@ def test_members_are_state_gated(list_path, variables): ) -# household_benefits needs no state gate, because most members are national. -@pytest.mark.parametrize( - "list_path", [*AGGREGATE_LISTS, "gov/household/household_benefits.yaml"] -) +# household_benefits and spm_unit_benefits need no state gate, because most +# members are national. +BENEFIT_LISTS = [ + "gov/household/household_benefits.yaml", + "gov/household/spm_unit_benefits.yaml", +] + + +@pytest.mark.parametrize("list_path", [*AGGREGATE_LISTS, *BENEFIT_LISTS]) def test_no_silent_removals_between_year_blocks(list_path): blocks = blocks_of(list_path) for (_, prev), (date, curr) in zip(blocks, blocks[1:]): @@ -173,143 +180,69 @@ def test_no_silent_removals_between_year_blocks(list_path): ) -# --- Reform-side copies of the benefit aggregates ------------------------- +@pytest.mark.parametrize("list_path", BENEFIT_LISTS) +def test_benefit_list_members_are_defined_variables(list_path, variables): + undefined = sorted(members_of(list_path) - set(variables)) + assert not undefined, ( + f"{list_path} references variables that do not exist: {undefined}" + ) -AGGREGATE_VARIABLES = ("household_benefits", "spm_unit_benefits") -BASELINE_SPM_BENEFITS_SOURCE = ( - PACKAGE / "variables/household/income/spm_unit/spm_unit_benefits.py" -) -BASELINE_HOUSEHOLD_BENEFITS_LIST = "gov/household/household_benefits.yaml" +@pytest.mark.parametrize("list_path", BENEFIT_LISTS) +def test_benefit_lists_hold_each_member_once(list_path): + for date, members in blocks_of(list_path): + repeated = sorted(m for m in set(members) if members.count(m) > 1) + assert not repeated, f"{list_path} block {date} repeats {repeated}" -# Contributed programs that a reform may legitimately leave out of its own -# copy of an aggregate, keyed by (reform source path, variable name). -ALLOWED_REFORM_OMISSIONS: dict[tuple[str, str], set[str]] = {} +# --- Reforms extend the benefit lists rather than copy them ---------------- -def contrib_variable_names(): - """Variables defined under variables/contrib, by source-file location.""" - names = set() - for source in (PACKAGE / "variables/contrib").rglob("*.py"): - names.update(re.findall(r"^class (\w+)\(Variable\)", source.read_text(), re.M)) - return names +AGGREGATE_VARIABLES = ("household_benefits", "spm_unit_benefits") -def benefits_lists_in(source_path): - """(variable name, members) for each literal BENEFITS list in a file. +def aggregate_redefinitions(source_text): + """Names of the benefit aggregates that a module defines a class for. - Only plain list literals are returned. A reform that rebuilds its list - from the parameter (or filters an existing one) has no literal to check. + Reform modules define their variables inside a create_* function, so the + walk covers nested classes too. """ - tree = ast.parse(source_path.read_text()) - found = [] - for node in ast.walk(tree): - if not isinstance(node, ast.ClassDef) or node.name not in AGGREGATE_VARIABLES: - continue - for statement in ast.walk(node): - if not isinstance(statement, ast.Assign): - continue - targets = [t.id for t in statement.targets if isinstance(t, ast.Name)] - if "BENEFITS" not in targets: - continue - if not isinstance(statement.value, ast.List): - continue - found.append( - ( - node.name, - [ - element.value - for element in statement.value.elts - if isinstance(element, ast.Constant) - ], - ) - ) - return found - - -def reform_aggregate_overrides(): - cases = [] - for source in sorted((PACKAGE / "reforms").rglob("*.py")): - for variable_name, members in benefits_lists_in(source): - cases.append( - (str(source.relative_to(PACKAGE)), variable_name, tuple(members)) - ) - return cases - - -REFORM_AGGREGATE_OVERRIDES = reform_aggregate_overrides() - - -def baseline_members(variable_name): - if variable_name == "spm_unit_benefits": - lists = benefits_lists_in(BASELINE_SPM_BENEFITS_SOURCE) - assert len(lists) == 1, "baseline spm_unit_benefits changed shape" - return set(lists[0][1]) - return members_of(BASELINE_HOUSEHOLD_BENEFITS_LIST) - - -def test_reform_aggregate_overrides_are_discovered(): - """The scan below is only a guard while it still finds the copies.""" - found = {(path, variable) for path, variable, _ in REFORM_AGGREGATE_OVERRIDES} - assert len(found) >= 6, ( - f"expected the reform-side benefit aggregate copies, found {sorted(found)}" + return sorted( + node.name + for node in ast.walk(ast.parse(source_text)) + if isinstance(node, ast.ClassDef) and node.name in AGGREGATE_VARIABLES ) -@pytest.mark.parametrize( - "source_path,variable_name,members", - REFORM_AGGREGATE_OVERRIDES, - ids=[f"{path}::{variable}" for path, variable, _ in REFORM_AGGREGATE_OVERRIDES], -) -def test_reform_aggregates_keep_baseline_contrib_entries( - source_path, variable_name, members -): - contrib = contrib_variable_names() - expected = baseline_members(variable_name) & contrib - allowed = ALLOWED_REFORM_OMISSIONS.get((source_path, variable_name), set()) - missing = sorted(expected - set(members) - allowed) - assert not missing, ( - f"{source_path} redefines {variable_name} with its own list and drops " - f"{missing}, which the baseline {variable_name} counts. Add the entry " - "here too, or record a deliberate omission in " - "ALLOWED_REFORM_OMISSIONS in this test." +def test_aggregate_redefinitions_finds_a_nested_copy(): + """The scan below is only a guard while it would find a copy.""" + source = ( + "def create_reform():\n" + " class spm_unit_benefits(Variable):\n" + " pass\n" + " class household_benefits(Variable):\n" + " pass\n" + " class spm_unit_net_income(Variable):\n" + " pass\n" ) + assert aggregate_redefinitions(source) == [ + "household_benefits", + "spm_unit_benefits", + ] -# Cash programs that household_benefits and spm_unit_benefits both count. A -# reform's copy that drops one takes the payment away from every household in -# the simulation, not only the reform's target group. -SHARED_CASH_PROGRAMS = { - "spm_unit_benefits": { - "ak_energy_relief", - "ca_capi", - "ca_la_expectant_parent_payment", - "ca_la_infant_supplement", - "co_denver_property_tax_relief", - "ma_eaedc", - "nj_property_tax_relief", - "ny_drive_clean_rebate", - }, - "household_benefits": { - "ak_energy_relief", - "household_local_benefits", - "ny_drive_clean_rebate", - }, -} - - -@pytest.mark.parametrize( - "source_path,variable_name,members", - REFORM_AGGREGATE_OVERRIDES, - ids=[f"{path}::{variable}" for path, variable, _ in REFORM_AGGREGATE_OVERRIDES], -) -def test_reform_aggregates_keep_shared_cash_programs( - source_path, variable_name, members -): - missing = sorted(SHARED_CASH_PROGRAMS[variable_name] - set(members)) - assert not missing, ( - f"{source_path} redefines {variable_name} and drops {missing}, which " - "both income totals count. Add them to this copy." +def test_reforms_extend_benefit_lists_rather_than_copy_them(): + copies = [ + f"{source.relative_to(PACKAGE)}::{name}" + for source in sorted((PACKAGE / "reforms").rglob("*.py")) + for name in aggregate_redefinitions(source.read_text()) + ] + assert not copies, ( + f"These reforms redefine a benefit aggregate: {copies}. A copy of the " + "list falls behind the baseline, and every program it misses drops " + "out of income for every household the reform applies to (#10226). " + "Add the reform's own programs to gov.household.household_benefits or " + "gov.household.spm_unit_benefits with add_to_list_parameter " + "(policyengine_us/tools/parameters.py) in its modify_parameters." ) diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index 165d3b89f10..4baaeedac57 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -171,12 +171,11 @@ def household_members(year): return (members | nested) - {"household_state_benefits", "household_local_benefits"} -def spm_members(): +def spm_members(year): + """spm_unit_benefits: its parameter list and what its formula appends.""" source = PACKAGE / "variables/household/income/spm_unit/spm_unit_benefits.py" - text = source.read_text() - listed = set(re.findall(r'^\s+"([a-z0-9_]+)",', text, re.M)) - appended = set(re.findall(r'BENEFITS\.append\("([a-z0-9_]+)"\)', text)) - return listed | appended + appended = set(re.findall(r'\.append\("([a-z0-9_]+)"\)', source.read_text())) + return block_for("spm_unit_benefits", year) | appended def person_market_members(): @@ -187,7 +186,7 @@ def person_market_members(): @pytest.mark.parametrize("year", YEARS) def test_benefit_lists_differ_only_where_documented(year): household = {name for name in household_members(year) if not is_child_care(name)} - spm = {name for name in spm_members() if not is_child_care(name)} + spm = {name for name in spm_members(year) if not is_child_care(name)} household_extra = household - spm - set(HOUSEHOLD_ONLY) spm_extra = spm - household - set(SPM_ONLY) @@ -220,14 +219,17 @@ def test_every_gross_income_source_reaches_spm_resources_or_is_documented(): ) assert (counted | left_out) - sources == set(), "stale entries above" - resources = person_market_members() | spm_members() - uncounted = { - source: resource - for source, resource in GROSS_INCOME_IN_RESOURCES.items() - if resource not in resources - } - assert uncounted == {}, f"mapped to a variable SPM resources lack: {uncounted}" - assert not left_out & resources + for year in YEARS: + resources = person_market_members() | spm_members(year) + uncounted = { + source: resource + for source, resource in GROSS_INCOME_IN_RESOURCES.items() + if resource not in resources + } + assert uncounted == {}, ( + f"{year}: mapped to a variable SPM resources lack: {uncounted}" + ) + assert not left_out & resources, year def market_situation(name, entity, amount, year=2026, state="TX"): diff --git a/policyengine_us/tests/test_reform_benefit_lists.py b/policyengine_us/tests/test_reform_benefit_lists.py new file mode 100644 index 00000000000..ba4f65eeea3 --- /dev/null +++ b/policyengine_us/tests/test_reform_benefit_lists.py @@ -0,0 +1,401 @@ +"""Reforms that pay a benefit add it to the baseline benefit lists. + +The Texas rebate, the End Child Poverty Act, its Economic Dignity for All +Agenda version and the BOOST Act credit each pay a benefit, and each replaced +spm_unit_benefits with its own copy of the program list. The copies fell behind +the baseline, and every program a copy missed dropped out of SPM resources for +every household the reform applied to (#10226): + +- Texas missed 19 state SSI supplements, and the three Tlaib copies missed 42 + supplements, Washington's cash programs (wa_sfa, wa_rca) and the Washington + and West Virginia child care subsidies. +- The Tlaib copies listed acp and ebb, which spm_unit_net_income also adds, so + ACP and EBB counted twice in SPM resources. +- The ECPA and BOOST copies of household_benefits missed CSFP and + household_health_benefits. + +Each reform now adds its own variables to the gov.household lists that the +baseline aggregates read, through add_to_list_parameter. These invariants hold +for every input: + +1. add_to_list_parameter keeps every dated value of a list and its members in + order, appends only the names a value lacks, and changes nothing when it + runs again. It writes new lists, leaving the names it gets and the member + lists it reads unchanged, and it changes values only through + Parameter.update, so the parent node returns the new lists. Every call + marks the parameter and its parent modified, even a call that adds nothing. +2. Under each reform, each benefit list in each year is the baseline list, + followed by the reform's own additions and nothing else. A reformed system + marks modified exactly the lists its reform changes, and current law marks + none. +3. Applying a reform a second time changes no list. A simulation can apply a + structural reform twice: once when its system is built, once at start. +4. For any household, spm_unit_benefits and household_benefits equal the sum + of the baseline list's programs and the reform's own additions, each as the + simulation computes it; spm_unit_benefits also adds the SPM's capped housing + subsidy. A program that a copy drops, or that it counts twice, breaks the + sum. +5. A dollar of ACP or EBB adds a dollar to spm_unit_net_income, under current + law and under each reform. +""" + +import gc +from datetime import date, timedelta + +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import instant + +from policyengine_us import Simulation +from policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit import ( + boost_middle_class_tax_credit, +) +from policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.edaa_end_child_poverty_act import ( + end_child_poverty_act as edaa_end_child_poverty_act, +) +from policyengine_us.reforms.congress.tlaib.end_child_poverty_act import ( + end_child_poverty_act, +) +from policyengine_us.reforms.states.tx.rebate.tx_rebate import tx_rebate +from policyengine_us.system import CountryTaxBenefitSystem, system +from policyengine_us.tools.parameters import add_to_list_parameter + +STATES = ( + "AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS " + "MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY" +).split() + +LISTS = ("spm_unit_benefits", "household_benefits", "household_state_benefits") + +# The structural reforms that pay a benefit, and the variable each adds to the +# household and SPM benefit lists. +REFORMS = { + "tx_rebate": (tx_rebate, "tx_rebate"), + "end_child_poverty_act": (end_child_poverty_act, "ecpa_child_benefit"), + "edaa_end_child_poverty_act": (edaa_end_child_poverty_act, "ecpa_child_benefit"), + "boost_middle_class_tax_credit": ( + boost_middle_class_tax_credit, + "boost_middle_class_tax_credit_paid_through_ssa", + ), +} +OWN_BENEFITS = sorted({benefit for _, benefit in REFORMS.values()}) + +# What the spm_unit_benefits formula adds to its parameter list under current +# law. The housing subsidy stays out of the parameter so that no other +# aggregate reads an SPM valuation cap. +SPM_FORMULA_BENEFITS = ["spm_unit_capped_housing_subsidy"] + + +def additions(name, list_name, year): + """The variables a reform adds to a benefit list in a year.""" + if name == "baseline": + return [] + own = REFORMS[name][1] + if name == "tx_rebate": + # A state benefit: household_benefits reads it through + # household_state_benefits, which carries it in 2027, its paid year. + if list_name == "spm_unit_benefits": + return [own] + if list_name == "household_state_benefits" and year == 2027: + return [own] + return [] + if list_name == "household_state_benefits": + return [] + return [own] + + +def benefit_list(tax_benefit_system, list_name, year): + """A benefit list as formulas read it in a year. + + Reading the one parameter, not parameters(year), keeps the test from + building and caching the whole parameter tree for every year it draws. + """ + parameter = getattr(tax_benefit_system.parameters.gov.household, list_name) + return list(parameter(f"{year}-01-01")) + + +# --- 1. add_to_list_parameter -------------------------------------------- + +NAMES = st.sampled_from("abcdefgh") +DAYS = st.dates(date(2000, 1, 1), date(2040, 12, 31)) + + +@st.composite +def dated_lists(draw): + """A list parameter's dated values: {date: members}. + + A value can start on any day. None marks a gap, as a null does in a + parameter file. Members can repeat, and the helper must keep them as + they are. + """ + starts = draw(st.lists(DAYS, min_size=1, max_size=4, unique=True)) + return { + start.isoformat(): draw(st.none() | st.lists(NAMES, max_size=5)) + for start in sorted(starts) + } + + +def list_parameter(values): + node = ParameterNode( + "root", data={"members": {"values": values, "metadata": {"unit": "list"}}} + ) + return node.members + + +def value_on(values, day): + """The members a dated list holds on a day, or None before its first.""" + starts = [start for start in sorted(values) if start <= day] + return values[starts[-1]] if starts else None + + +def node_value(parameter, day): + """The members the parameter's parent node returns on a day, as formulas + read them. A node leaves out a child with no value on that day.""" + at_instant = parameter.parent(instant(day)) + return at_instant["members"] if "members" in at_instant else None + + +@settings(max_examples=200, deadline=None) +@given( + values=dated_lists(), + names=st.lists(NAMES, min_size=1, max_size=4), + days=st.lists(DAYS, max_size=6), +) +def test_add_to_list_parameter_appends_only_missing_names(values, names, days): + # The expectation comes from copies taken before the call. The parameter + # holds the drawn member lists themselves, so a helper that changed them, + # or the names, in place would otherwise move the expectation with it. + unique_names = list(dict.fromkeys(names)) + expected = { + start: ( + None + if members is None + else members + [name for name in unique_names if name not in members] + ) + for start, members in values.items() + } + names_before = list(names) + values_before = { + start: None if members is None else list(members) + for start, members in values.items() + } + + parameter = list_parameter(values) + # Core keeps the lists it reads, so the checks below that the drawn lists + # stay unchanged see an edit the helper makes to the parameter's own. + assert all( + value.value is values[value.instant_str] for value in parameter.values_list + ) + # Each value's first day and the day before it hold the stop dates the + # helper computes, so check them along with the drawn days. + checked = [day.isoformat() for day in days] + for start in values: + first_day = date.fromisoformat(start) + checked += [first_day.isoformat(), (first_day - timedelta(days=1)).isoformat()] + # The parent node caches what it returns for each day, and + # Parameter.update clears that cache. Reading it now fills the cache, so a + # helper that changed the values another way would leave the node, and the + # formulas that read through it, with the old lists. + for day in checked: + assert node_value(parameter, day) == value_on(values_before, day) + assert not parameter.modified and not parameter.parent.modified + + add_to_list_parameter(parameter, names) + + # The helper writes new lists. A clone of a parameter shares its member + # lists (ParameterAtInstant.clone copies its dict shallowly), so a helper + # that edited one in place would change every clone that shares it. + assert names == names_before + assert values == values_before + starts = sorted(value.instant_str for value in parameter.values_list) + assert starts == sorted(values) + # Before a simulation serves a variable from its macro cache, it checks the + # modified flag on each parameter or folder the variable depends on, so + # the helper marks the parameter and each node above it. + assert parameter.modified and parameter.parent.modified + for day in checked: + assert parameter(instant(day)) == value_on(expected, day) + assert node_value(parameter, day) == value_on(expected, day) + + # CountryTaxBenefitSystem clears every flag below the root before it + # applies a reform it is given for the second time. That call adds + # nothing, and must mark the parameter again. + parameter.modified = parameter.parent.modified = False + add_to_list_parameter(parameter, names) + assert parameter.modified and parameter.parent.modified + for day in checked: + assert parameter(instant(day)) == value_on(expected, day) + assert node_value(parameter, day) == value_on(expected, day) + + +def test_add_to_list_parameter_keeps_dates_from_year_zero(): + parameter = list_parameter({"0000-01-01": ["a"], "2026-01-01": ["a", "b"]}) + add_to_list_parameter(parameter, ["c", "b"]) + assert parameter(instant("1990-01-01")) == ["a", "c", "b"] + assert parameter(instant("2025-12-31")) == ["a", "c", "b"] + assert parameter(instant("2026-01-01")) == ["a", "b", "c"] + + +# --- 2 to 5. The reforms --------------------------------------------------- + + +@pytest.fixture(scope="module", params=["baseline", *REFORMS]) +def policy(request): + """(name, system): one reformed system at a time, each about 0.7 GB.""" + gc.collect() + name = request.param + if name == "baseline": + yield name, system + return + reformed = CountryTaxBenefitSystem(reform=REFORMS[name][0]) + yield name, reformed + del reformed + gc.collect() + + +@settings(max_examples=60, deadline=None) +@given(year=st.integers(2015, 2035), list_name=st.sampled_from(LISTS)) +def test_reform_lists_are_baseline_lists_plus_own_additions(policy, year, list_name): + name, reformed = policy + assert benefit_list(reformed, list_name, year) == ( + benefit_list(system, list_name, year) + additions(name, list_name, year) + ) + + +def test_reformed_systems_mark_exactly_the_lists_they_change(policy): + # The macro cache reads a modified flag as "differs from current law". + # The fixture passes each reform to CountryTaxBenefitSystem, which clears + # every flag below the root between its two applications of that reform. + name, reformed = policy + node = reformed.parameters.gov.household + for list_name in LISTS: + changed = any(additions(name, list_name, year) for year in range(2015, 2036)) + assert getattr(node, list_name).modified == changed, list_name + assert node.modified == (name != "baseline") + + +def test_applying_a_reform_twice_changes_no_list(policy): + name, reformed = policy + if name == "baseline": + pytest.skip("current law applies no reform") + twice = REFORMS[name][0](reformed) + for year in (2015, 2023, 2026, 2027, 2028, 2035): + for list_name in LISTS: + assert benefit_list(twice, list_name, year) == benefit_list( + reformed, list_name, year + ) + + +def program_state(program): + """The state whose program this is, from its prefix, or None if national.""" + prefix = program.split("_")[0].upper() + return prefix if len(prefix) == 2 and prefix in STATES else None + + +@st.composite +def households(draw): + """(state, year, {program: amount}) for a one-person household. + + The programs are drawn from the baseline SPM list, what its formula adds, + the reforms' own benefits and the broadband subsidies, which no benefit + list may hold. + """ + year = draw(st.integers(2023, 2027)) + state = draw(st.sampled_from(STATES)) + candidates = [ + program + for program in benefit_list(system, "spm_unit_benefits", year) + if program_state(program) in (None, state) + ] + [*SPM_FORMULA_BENEFITS, *OWN_BENEFITS, "acp", "ebb"] + programs = draw(st.lists(st.sampled_from(candidates), max_size=8, unique=True)) + amounts = st.integers(1, 30_000) + return state, year, {program: draw(amounts) for program in programs} + + +def one_person(tax_benefit_system, state, year, earnings, inputs): + """A one-person household, each input on its variable's entity.""" + groups = {"person": {}, "tax_unit": {}, "spm_unit": {}, "household": {}} + for name, value in inputs.items(): + if name in tax_benefit_system.variables: + entity = tax_benefit_system.variables[name].entity.key + groups[entity][name] = {year: value} + groups["person"].update(age={year: 40}, employment_income={year: earnings}) + groups["household"]["state_name"] = {year: state} + members = ["you"] + return { + "people": {"you": groups["person"]}, + "tax_units": {"tax_unit": {"members": members, **groups["tax_unit"]}}, + "spm_units": {"spm_unit": {"members": members, **groups["spm_unit"]}}, + "families": {"family": {"members": members}}, + "marital_units": {"marital_unit": {"members": members}}, + "households": {"household": {"members": members, **groups["household"]}}, + } + + +def summed(simulation, names, year): + return sum( + float(simulation.calculate(name, year, map_to="spm_unit").sum()) + for name in names + ) + + +# Totals run in float32, so allow for rounding; a dropped or repeated program +# moves a total by at least the $1 of its smallest amount. +ROUNDING = 0.5 + + +@settings(max_examples=25, deadline=None) +@given(household=households()) +# Programs the old reform copies dropped, and the broadband subsidies the Tlaib +# copies counted twice. +@example(household=("WY", 2027, {"wy_ssp": 240, "tx_rebate": 1_500})) +@example(household=("WA", 2026, {"wa_sfa": 1_000, "wa_rca": 500, "wa_ssp": 300})) +@example(household=("NY", 2024, {"ny_ssp": 1_052, "acp": 360, "ebb": 120})) +@example(household=("CA", 2026, {"ca_state_supplement": 2_000, "ssi": 4_000})) +def test_benefit_totals_sum_the_baseline_lists_and_own_additions(policy, household): + name, reformed = policy + state, year, inputs = household + simulation = Simulation( + tax_benefit_system=reformed, + situation=one_person(reformed, state, year, 500_000, inputs), + ) + + spm_programs = ( + benefit_list(system, "spm_unit_benefits", year) + + additions(name, "spm_unit_benefits", year) + + SPM_FORMULA_BENEFITS + ) + assert float(simulation.calculate("spm_unit_benefits", year).sum()) == ( + pytest.approx(summed(simulation, spm_programs, year), abs=ROUNDING) + ) + + household_programs = benefit_list(system, "household_benefits", year) + ( + additions(name, "household_benefits", year) + ) + assert float(simulation.calculate("household_benefits", year).sum()) == ( + pytest.approx(summed(simulation, household_programs, year), abs=ROUNDING) + ) + + +@settings(max_examples=8, deadline=None) +@given( + program=st.sampled_from(["acp", "ebb"]), + amount=st.integers(1, 5_000), + year=st.integers(2022, 2024), + state=st.sampled_from(STATES), +) +def test_broadband_subsidies_reach_spm_net_income_once( + policy, program, amount, year, state +): + name, reformed = policy + + def net_income(value): + simulation = Simulation( + tax_benefit_system=reformed, + situation=one_person(reformed, state, year, 100_000, {program: value}), + ) + return float(simulation.calculate("spm_unit_net_income", year).sum()) + + assert net_income(amount) - net_income(0) == pytest.approx(amount, abs=0.05) diff --git a/policyengine_us/tools/parameters.py b/policyengine_us/tools/parameters.py index d95547f8dc5..0632ce6d267 100644 --- a/policyengine_us/tools/parameters.py +++ b/policyengine_us/tools/parameters.py @@ -13,6 +13,47 @@ def str_to_instant(s): return periods.Instant(tuple(map(lambda s: int(s), s.split("-")))) +def add_to_list_parameter(parameter, names) -> None: + """Add variable names to every dated value of a list parameter. + + A reform that pays a new benefit or credit calls this in its + ``modify_parameters`` to add its variable to an aggregate's list, such as + ``gov.household.spm_unit_benefits``. Every baseline member stays in the + list in every period, so the reform does not redefine the aggregate, and a + program the baseline list gains later reaches the reform too. + + The names follow the existing members, in the order given. A name already + in the list for a period is not added again for that period, so a reform + applied twice, or two reforms that add the same variable, count it once. + + Every call marks the parameter, and each node above it, modified, even a + call that adds nothing. Core's macro cache reads the flag as "differs from + current law". ``CountryTaxBenefitSystem`` clears every flag below the root + before it applies a reform it is given for the second time, and that + second call finds each name present. + """ + names = list(dict.fromkeys(names)) + # values_list runs newest first. Each value lasts until the day before the + # next newer value starts; the newest has no end. + newer_start = None + for value_at_instant in list(parameter.values_list): + members = value_at_instant.value + if members is not None: + missing = [name for name in names if name not in members] + if missing: + parameter.update( + start=value_at_instant.instant_str, + stop=( + None + if newer_start is None + else str_to_instant(newer_start).offset(-1, "day") + ), + value=list(members) + missing, + ) + newer_start = value_at_instant.instant_str + parameter.mark_as_modified() + + def backdate_parameters(root: str = None, first_instant: str = "2021-01-01") -> Reform: first_instant = str_to_instant(first_instant) node = root diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py index 0295c517ff8..8a848bbafee 100644 --- a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py @@ -9,106 +9,14 @@ class spm_unit_benefits(Variable): unit = USD def formula(spm_unit, period, parameters): - BENEFITS = [ - "social_security", - "ssi", - "in_ssp", - "ct_ssp", - "ga_ssp", - "al_ssp", - "ak_ssp", - "dc_ossp", # DC benefits - "id_aabd", # Idaho benefits - "vt_aabd", # Vermont benefits - "ky_ssp", # Kentucky benefits - "de_ssp", # Delaware benefits - "fl_oss", - "nj_ssp", # New Jersey benefits - "nh_ssp", # New Hampshire benefits - "pa_ssp", # Pennsylvania benefits - "wy_ssp", # Wyoming benefits - "or_ssp", # Oregon benefits - "nc_sa", # North Carolina benefits - "mt_ssp", # Montana benefits - "ok_ssp", # Oklahoma benefits - "oh_rss", # Ohio benefits - "ny_ssp", # New York benefits - "ri_ssp", # Rhode Island benefits - "ut_ssp", # Utah benefits - "sd_ssp", # South Dakota benefits - "va_ag", # Virginia benefits - "ia_ssa", # Iowa benefits - "ks_sspp", # Kansas benefits - "hi_oss", - "la_oss", # Louisiana benefits - "ma_state_supplement", # Massachusetts benefits - "md_paa", # Maryland benefits - "wa_ssp", # Washington benefits - "wi_ssp", # Wisconsin benefits - "mi_ssp", # Michigan benefits - "me_ssp", # Maine benefits - "mo_ssp", # Missouri benefits - "mn_msa", # Minnesota benefits - "ne_aabd", # Nebraska benefits - "nv_oss", # Nevada benefits - # California programs. - "ca_cvrp", # California Clean Vehicle Rebate Project. - # California SSI state supplement (SSP). The formula nets federal - # SSI and countable income out of the state payment standard, so - # summing it with ssi does not double-count. - "ca_state_supplement", - # Colorado programs. - "co_ccap_subsidy", - "co_state_supplement", - "co_oap", - # Washington programs. - "wa_child_care_subsidies", - # New Mexico programs. - "nm_ssi_state_supplement", - # South Carolina programs. - "sc_ssi_state_supplement", - # Texas programs. - "tx_ssi_state_supplement", - # West Virginia programs. - "wv_child_care_subsidies", - "snap", - "wic", - "free_school_meals", - "reduced_price_school_meals", - "child_support_received", - "workers_compensation", - "educational_assistance", - "financial_assistance", - "survivor_benefits", - "spm_unit_energy_subsidy", - "tanf", - # Washington (WA) cash-assistance programs. wa_sfa and wa_rca - # sit alongside the federal TANF aggregator entry; under default - # rules these three are mutually exclusive at the SPM-unit level - # so summing them does not double-count. - "wa_sfa", - "wa_rca", - "high_efficiency_electric_home_rebate", - "residential_efficiency_electrification_rebate", - "total_unemployment_compensation", - # One-time energy relief payments. - # Paid at the same time as the Alaska Permanent Fund Dividend, - # which is part of IRS gross income. - "ak_energy_relief", - # Contributed. - "basic_income", - "trump_dividend", - # Cash programs that household_benefits also counts. - "ca_capi", # California Cash Assistance Program for Immigrants. - "ca_la_expectant_parent_payment", # Los Angeles County. - "ca_la_infant_supplement", # Los Angeles County. - "co_denver_property_tax_relief", # Denver. - "ma_eaedc", # Massachusetts. - "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. - "ny_drive_clean_rebate", - ] - if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: - BENEFITS.append("assigned_aca_ptc") - if not parameters(period).gov.hud.abolition: - BENEFITS.append("spm_unit_capped_housing_subsidy") - return add(spm_unit, period, BENEFITS) + p = parameters(period) + # A reform that pays a new benefit adds it to this parameter rather + # than redefining this variable, so it counts every baseline program. + benefits = list(p.gov.household.spm_unit_benefits) + if p.gov.contrib.ubi_center.flat_tax.deduct_ptc: + benefits.append("assigned_aca_ptc") + # The SPM's capped value of housing assistance. It stays out of the + # parameter so that no other aggregate can read an SPM valuation cap. + if not p.gov.hud.abolition: + benefits.append("spm_unit_capped_housing_subsidy") + return add(spm_unit, period, benefits)