diff --git a/changelog.d/reconcile-household-spm-cash.fixed.md b/changelog.d/reconcile-household-spm-cash.fixed.md new file mode 100644 index 00000000000..0cc8f9076d3 --- /dev/null +++ b/changelog.d/reconcile-household-spm-cash.fixed.md @@ -0,0 +1 @@ +Count the same cash payments in the household and SPM income totals: the Alaska Permanent Fund Dividend, New Jersey property tax relief, Massachusetts EAEDC, California CAPI and two Los Angeles County payments in SPM resources, and Alaska energy relief from 2024, New York's Drive Clean rebate and Denver property tax relief in household benefits. diff --git a/policyengine_us/parameters/gov/household/household_benefits.yaml b/policyengine_us/parameters/gov/household/household_benefits.yaml index aaec818b609..2c48d38ca0e 100644 --- a/policyengine_us/parameters/gov/household/household_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_benefits.yaml @@ -24,11 +24,14 @@ values: # Paid at the same time as the Alaska Permanent Fund Dividend, # which is part of IRS gross income. - ak_energy_relief + # Electric vehicle rebates. + - ny_drive_clean_rebate # Contributed. - basic_income - trump_dividend - housing_assistance - household_state_benefits + - household_local_benefits - household_health_benefits 2024-01-01: - social_security @@ -50,11 +53,18 @@ values: - financial_assistance - survivor_benefits - household_head_start_benefits + # 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 + # Electric vehicle rebates. + - ny_drive_clean_rebate # Contributed. - basic_income - trump_dividend - housing_assistance - household_state_benefits + - household_local_benefits - commodity_supplemental_food_program - household_health_benefits diff --git a/policyengine_us/parameters/gov/household/household_state_benefits.yaml b/policyengine_us/parameters/gov/household/household_state_benefits.yaml index e01d10fc5e2..3dc79dda9c9 100644 --- a/policyengine_us/parameters/gov/household/household_state_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_state_benefits.yaml @@ -115,6 +115,8 @@ values: - wa_ssp - wa_sfa - wa_rca + # New Jersey benefits + - nj_property_tax_relief # Louisiana benefits - la_oss # Washington benefits @@ -243,6 +245,8 @@ values: - wa_ssp - wa_sfa - wa_rca + # New Jersey benefits + - nj_property_tax_relief # Louisiana benefits - la_oss # Arkansas benefits @@ -376,6 +380,8 @@ values: - wa_ssp - wa_sfa - wa_rca + # New Jersey benefits + - nj_property_tax_relief # Louisiana benefits - la_oss # Arkansas 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 3bc42955517..d4a2face50a 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 @@ -75,7 +75,10 @@ def formula(household, period, parameters): "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 = [ @@ -130,6 +133,14 @@ def formula(spm_unit, period, parameters): # 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: 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 47eba8ba25d..4b6552e52e3 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 @@ -142,6 +142,13 @@ def formula(spm_unit, period, parameters): "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", ] 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 42729a88112..7dea7b0defc 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -111,7 +111,10 @@ def formula(household, period, parameters): "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: @@ -158,6 +161,14 @@ def formula(spm_unit, period, parameters): # 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", ] diff --git a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py index 4d5d1f4c86a..3ae570a9999 100644 --- a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py +++ b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py @@ -117,6 +117,13 @@ def formula(spm_unit, period, parameters): # 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", ] diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml index 1a3c84f6175..dfbde8229df 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_benefits.yaml @@ -110,3 +110,66 @@ household_benefits: 12_000 # The SPM resource valuation remains separate. spm_unit_benefits: 1_000 + +- name: Alaska's 2024 energy relief reaches household benefits for each person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult1: + age: 40 + employment_income: 500_000 + adult2: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult1, adult2] + spm_units: + spm_unit: + members: [adult1, adult2] + households: + household: + members: [adult1, adult2] + state_code: AK + output: + ak_energy_relief: [298.17, 298.17] + household_benefits: 2 * 298.17 + spm_unit_benefits: 2 * 298.17 + +- name: Alaska's 2026 energy relief reaches household benefits. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: AK + output: + household_benefits: 200 + spm_unit_benefits: 200 + +- name: New York's Drive Clean rebate reaches household benefits. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: NY + ny_drive_clean_rebate: 2_000 + output: + household_benefits: 2_000 + spm_unit_benefits: 2_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml index 7017abac77c..ea555b85a89 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml @@ -160,3 +160,27 @@ state_code: WI output: household_net_income: 41_154 + +- name: An Alaskan's dividend and energy relief reach both net incomes. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + spm_units: + spm_unit: + members: [adult] + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + households: + household: + members: [adult] + state_code: AK + household_tax_before_refundable_credits: 0 + household_refundable_tax_credits: 0 + household_health_costs: 0 + household_health_benefits: 0 + output: + spm_unit_net_income: 500_000 + 1_000 + 200 + household_net_income: 500_000 + 1_000 + 200 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml b/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml index 0d0ba37e0c6..c56256f6f54 100644 --- a/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml @@ -20,3 +20,12 @@ estate_income: -300 output: market_income: 700 + +- name: The Alaska Permanent Fund Dividend is market income, as in the household list. + period: 2026 + input: + state_code: AK + output: + ak_permanent_fund_dividend: 1_000 + market_income: 1_000 + household_market_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_benefits.yaml b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_benefits.yaml index 3f0849f633c..2c8a14090b0 100644 --- a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_benefits.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_benefits.yaml @@ -162,3 +162,143 @@ unemployment_compensation_months: 6 snap: 1_368 spm_unit_benefits: 11_586 + +# A one-adult household with enough earnings to rule out every means-tested +# benefit, so each case isolates one payment. Each case also checks that +# household_benefits counts the same payment. +- name: New Jersey property tax relief counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + nj_property_tax_relief: 1_750 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: NJ + output: + spm_unit_benefits: 1_750 + household_benefits: 1_750 + +- name: New Jersey property tax relief counts in both benefit totals before 2026. + period: 2025 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + nj_property_tax_relief: 1_750 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: NJ + output: + spm_unit_benefits: 1_750 + household_benefits: 1_750 + +- name: Massachusetts EAEDC counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + spm_units: + spm_unit: + members: [adult] + ma_eaedc: 3_600 + households: + household: + members: [adult] + state_code: MA + output: + spm_unit_benefits: 3_600 + household_benefits: 3_600 + +- name: California CAPI counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + spm_units: + spm_unit: + members: [adult] + ca_capi: 4_800 + households: + household: + members: [adult] + state_code: CA + output: + spm_unit_benefits: 4_800 + household_benefits: 4_800 + +- name: Denver property tax relief counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + spm_units: + spm_unit: + members: [adult] + co_denver_property_tax_relief: 1_200 + households: + household: + members: [adult] + state_code: CO + output: + spm_unit_benefits: 1_200 + household_local_benefits: 1_200 + household_benefits: 1_200 + +- name: Household-level payments split across SPM units by person, not repeated. + period: 2026 + input: + people: + first: + age: 40 + employment_income: 500_000 + second: + age: 40 + employment_income: 500_000 + third: + age: 40 + employment_income: 500_000 + fourth: + age: 40 + employment_income: 500_000 + tax_units: + first_unit: + members: [first, second] + second_unit: + members: [third, fourth] + spm_units: + first_unit: + members: [first, second] + second_unit: + members: [third, fourth] + households: + household: + members: [first, second, third, fourth] + state_code: CA + ca_la_infant_supplement: 2_000 + output: + spm_unit_benefits: [1_000, 1_000] + household_benefits: 2_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml index 2bd059456f5..650d1aaff46 100644 --- a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml @@ -11,3 +11,38 @@ members: [p1, p2] output: spm_unit_market_income: 1 + 2 + +- name: Each Alaskan's dividend reaches SPM unit market income. + period: 2026 + input: + people: + parent: + age: 40 + child: + age: 5 + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: AK + output: + spm_unit_market_income: 2 * 1_000 + household_market_income: 2 * 1_000 + +- name: No dividend reaches SPM unit market income outside Alaska. + period: 2026 + input: + people: + parent: + age: 40 + spm_units: + spm_unit: + members: [parent] + households: + household: + members: [parent] + state_code: WA + output: + spm_unit_market_income: 0 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 new file mode 100644 index 00000000000..e95a7ed8089 --- /dev/null +++ b/policyengine_us/tests/policy/reform/ak_energy_relief_in_reform_benefit_aggregates.yaml @@ -0,0 +1,60 @@ +# Alaska's energy relief must reach both benefit aggregates under the reforms +# that replace household_benefits and spm_unit_benefits with their own 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. +# +# This lives under tests/policy/reform for the reason the Trump dividend cases do: +# stacked reforms stall the congress structural batch. + +- name: Under ECPA, Alaska energy relief counts in both benefit aggregates. + 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 + people: + adult_one: + age: 40 + employment_income: 500_000 + adult_two: + age: 38 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult_one, adult_two] + spm_units: + spm_unit: + members: [adult_one, adult_two] + households: + household: + members: [adult_one, adult_two] + state_code: AK + output: + household_benefits: 596.34 + spm_unit_benefits: 596.34 + +- name: Under BOOST, Alaska energy relief counts in both benefit aggregates. + 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: false + people: + adult_one: + age: 40 + employment_income: 500_000 + adult_two: + age: 38 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult_one, adult_two] + spm_units: + spm_unit: + members: [adult_one, adult_two] + households: + household: + members: [adult_one, adult_two] + state_code: AK + output: + household_benefits: 596.34 + spm_unit_benefits: 596.34 diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index 06452fbdaaf..22287ba0703 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -111,7 +111,10 @@ def test_members_are_state_gated(list_path, variables): ) -@pytest.mark.parametrize("list_path", AGGREGATE_LISTS) +# household_benefits needs no state gate, because most members are national. +@pytest.mark.parametrize( + "list_path", [*AGGREGATE_LISTS, "gov/household/household_benefits.yaml"] +) def test_no_silent_removals_between_year_blocks(list_path): blocks = blocks_of(list_path) for (_, prev), (date, curr) in zip(blocks, blocks[1:]): @@ -227,3 +230,40 @@ def test_reform_aggregates_keep_baseline_contrib_entries( "here too, or record a deliberate omission in " "ALLOWED_REFORM_OMISSIONS in this test." ) + + +# 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." + ) diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py new file mode 100644 index 00000000000..901c44a04c7 --- /dev/null +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -0,0 +1,226 @@ +"""Invariants tying the household and SPM income totals together. + +household_net_income and spm_unit_net_income are two views of one family's +cash. Each total keeps its own list of what it adds, in two different files, and +the lists drift: a cash program reaches one total and not the other. + +Three invariants hold for every input. + +1. The list difference is exactly the documented one. Every program that one + total counts and the other does not appears below with its reason. +2. A dollar of a cash payment adds a dollar to both totals. +3. For a one-person household, the two net incomes differ only by the SPM's + subtractions and the items each measure defines differently. + +Child care stays out of this file: household_state_benefits and the SPM treat +subsidies differently on purpose, and #9405 and #10569 cover that difference. + +YAML pins one household per case. These tests need Hypothesis over amounts and +programs, every state, and a differential check of the two net income formulas. +""" + +import re +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +PACKAGE = Path(__file__).parent.parent +HOUSEHOLD_LISTS = PACKAGE / "parameters/gov/household" +# household_state_benefits starts in 2023. +YEARS = range(2023, 2027) +TOLERANCE = 0.02 + +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() + +# Counted by household_benefits and left out of spm_unit_benefits on purpose. +HOUSEHOLD_ONLY = { + # spm_unit_net_income adds these two itself. + "acp": "added directly in spm_unit_net_income", + "ebb": "added directly in spm_unit_net_income", + # Census section 4.3.1 lists the SPM's noncash benefits, and these are not on it. + "household_head_start_benefits": "not an SPM noncash benefit", + "household_health_benefits": "not an SPM noncash benefit", + "commodity_supplemental_food_program": "not an SPM noncash benefit", + # A lower bill, not a payment. + "ca_care": "utility bill discount", + "ca_fera": "utility bill discount", + "ca_la_ez_save": "utility bill discount", + "ca_riv_share_payment": "utility bill credit", + # The SPM values housing assistance at a capped amount of its own. + "housing_assistance": "spm_unit_capped_housing_subsidy in the SPM", +} + +# Counted by spm_unit_benefits and left out of household_benefits on purpose. +SPM_ONLY = { + "spm_unit_capped_housing_subsidy": "housing_assistance in household_benefits", + "spm_unit_energy_subsidy": "the SPM's own energy assistance valuation", + "assigned_aca_ptc": "only under the flat tax reform's deduct_ptc switch", +} + +# Sources in the household list that person market_income still lacks. +# #9919 tracks these. +MARKET_HOUSEHOLD_ONLY = { + "debt_relief", + "farm_rent_income", + "partnership_s_corp_income", +} + +# Cash payments, with the state that pays each and a year it pays. +CASH_PAYMENTS = [ + ("ak_energy_relief", "person", "AK", 2024), + ("ak_energy_relief", "person", "AK", 2026), + ("ak_permanent_fund_dividend", "person", "AK", 2026), + ("ca_capi", "spm_unit", "CA", 2026), + ("ca_la_expectant_parent_payment", "person", "CA", 2026), + ("ca_la_infant_supplement", "household", "CA", 2026), + ("co_denver_property_tax_relief", "spm_unit", "CO", 2026), + ("ma_eaedc", "spm_unit", "MA", 2026), + ("nj_property_tax_relief", "tax_unit", "NJ", 2026), + ("ny_drive_clean_rebate", "household", "NY", 2026), +] + + +def block_for(list_name, year): + raw = yaml.load( + (HOUSEHOLD_LISTS / f"{list_name}.yaml").read_text(), Loader=yaml.BaseLoader + ) + dated = sorted(item for item in raw["values"].items() if item[0] <= f"{year}-12-31") + return set(dated[-1][1]) if dated else set() + + +def is_child_care(name): + return "child_care" in name or "ccap" in name + + +def household_members(year): + """household_benefits with its nested lists opened up.""" + members = block_for("household_benefits", year) + assert "household_state_benefits" in members + assert "household_local_benefits" in members + nested = block_for("household_state_benefits", year) | block_for( + "household_local_benefits", year + ) + return (members | nested) - {"household_state_benefits", "household_local_benefits"} + + +def spm_members(): + 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 + + +def person_market_members(): + source = PACKAGE / "variables/household/income/person/general/market_income.py" + return set(re.findall(r'^\s+"([a-z0-9_]+)",', source.read_text(), re.M)) + + +@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)} + + household_extra = household - spm - set(HOUSEHOLD_ONLY) + spm_extra = spm - household - set(SPM_ONLY) + assert household_extra == set(), f"{year}: only in household: {household_extra}" + assert spm_extra == set(), f"{year}: only in SPM: {spm_extra}" + + +def test_market_income_differs_only_by_the_tracked_sources(): + household = block_for("market_income_sources", 2026) + assert household - person_market_members() == MARKET_HOUSEHOLD_ONLY + assert person_market_members() - household == set() + + +def one_person(state, earnings, year, **variables): + """A one-person household, with named inputs placed on their entities.""" + groups = {"person": {}, "tax_unit": {}, "spm_unit": {}, "household": {}} + for name, (entity, value) in variables.items(): + 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 total(simulation, names, year): + return sum(float(simulation.calculate(name, year).sum()) for name in names) + + +SPM_RESOURCES = ("spm_unit_market_income", "spm_unit_benefits") +HOUSEHOLD_RESOURCES = ("household_market_income", "household_benefits") + + +@settings(max_examples=20, deadline=None) +@given( + payment=st.sampled_from(CASH_PAYMENTS), + amount=st.integers(min_value=1, max_value=30_000), +) +def test_a_dollar_of_cash_adds_a_dollar_to_both_totals(payment, amount): + name, entity, state, year = payment + # Earnings this high rule out every means-tested response. + zero = Simulation(situation=one_person(state, 500_000, year, **{name: (entity, 0)})) + paid = Simulation( + situation=one_person(state, 500_000, year, **{name: (entity, amount)}) + ) + for names in (SPM_RESOURCES, HOUSEHOLD_RESOURCES): + change = total(paid, names, year) - total(zero, names, year) + assert change == pytest.approx(amount, abs=TOLERANCE), names + + +# Random states would miss the few that pay these programs, so every state runs. +@pytest.mark.parametrize("state", STATES) +@settings(max_examples=1, deadline=None) +@example(earnings=30_000) +@given(earnings=st.integers(min_value=0, max_value=300_000)) +def test_net_incomes_reconcile_for_one_person(state, earnings): + """A differential test of the two net income formulas. + + spm_unit_net_income subtracts work, medical and child support costs, and it + leaves out health benefits. household_net_income subtracts health costs and + counts the benefits and bill discounts listed in HOUSEHOLD_ONLY. Take those + away and the two totals agree. + """ + year = 2026 + # Rent makes New Jersey's relief positive, so that payment is under test too. + simulation = Simulation( + situation=one_person(state, earnings, year, pre_subsidy_rent=("person", 12_000)) + ) + + def value(name): + return total(simulation, [name], year) + + household_side = ( + value("household_net_income") + + value("household_health_costs") + - value("household_health_benefits") + - sum(value(name) for name in ("ca_care", "ca_fera", "ca_la_ez_save")) + - value("ca_riv_share_payment") + - value("commodity_supplemental_food_program") + - value("household_head_start_benefits") + - value("housing_assistance") + ) + spm_side = ( + value("spm_unit_net_income") + + value("spm_unit_spm_expenses") + - value("spm_unit_capped_housing_subsidy") + - value("spm_unit_energy_subsidy") + ) + assert np.isclose(household_side, spm_side, atol=TOLERANCE) diff --git a/policyengine_us/variables/household/income/person/general/market_income.py b/policyengine_us/variables/household/income/person/general/market_income.py index 6447143a423..35707b16cd4 100644 --- a/policyengine_us/variables/household/income/person/general/market_income.py +++ b/policyengine_us/variables/household/income/person/general/market_income.py @@ -28,5 +28,6 @@ def formula(person, period, parameters): "strike_benefits", "retirement_distributions", "military_retirement_income", + "ak_permanent_fund_dividend", ] return add(person, period, COMPONENTS) 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 118a9e0dc0d..0295c517ff8 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 @@ -98,6 +98,13 @@ def formula(spm_unit, period, parameters): # 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: