From 4f7c03c0ef92e597dbbb12f4a0d7d31cb012d71b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 16:53:49 -0500 Subject: [PATCH 1/4] Count the same cash payments in household and SPM income household_net_income and spm_unit_net_income keep separate lists, and the lists had drifted. SPM resources missed the Alaska Permanent Fund Dividend, New Jersey property tax relief, Massachusetts EAEDC, California CAPI and two Los Angeles County payments. Household benefits missed Alaska energy relief from 2024, New York's Drive Clean rebate and everything in household_local_benefits, which nothing added. Partly addresses #9919, which covers the other market income sources. Co-Authored-By: Claude Opus 5.5 --- .../reconcile-household-spm-cash.fixed.md | 1 + .../gov/household/household_benefits.yaml | 10 + .../boost/boost_middle_class_tax_credit.py | 10 + .../edaa_end_child_poverty_act.py | 7 + .../congress/tlaib/end_child_poverty_act.py | 10 + .../reforms/states/tx/rebate/tx_rebate.py | 7 + .../income/household/household_benefits.yaml | 60 +++++ .../household/household_net_income.yaml | 24 ++ .../income/person/market_income.yaml | 9 + .../income/spm_unit/spm_unit_benefits.yaml | 118 +++++++++ .../spm_unit/spm_unit_market_income.yaml | 35 +++ .../tests/test_aggregate_list_integrity.py | 40 ++- .../test_household_spm_resource_parity.py | 229 ++++++++++++++++++ .../income/person/general/market_income.py | 1 + .../income/spm_unit/spm_unit_benefits.py | 7 + 15 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 changelog.d/reconcile-household-spm-cash.fixed.md create mode 100644 policyengine_us/tests/test_household_spm_resource_parity.py 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/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..095fe62ed6e 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,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: 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..844b5d6d8b1 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,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", "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..bcaccf83f4f 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,63 @@ 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: + parent: + age: 40 + employment_income: 500_000 + child: + age: 15 + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: AK + household_health_benefits: 0 + 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..fd1cfe65817 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,121 @@ 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: 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/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index 06452fbdaaf..38364ede124 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,38 @@ 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": { + "ca_capi", + "ca_la_expectant_parent_payment", + "ca_la_infant_supplement", + "co_denver_property_tax_relief", + "ma_eaedc", + "nj_property_tax_relief", + }, + "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..fdde8776c3e --- /dev/null +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -0,0 +1,229 @@ +"""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. +""" + +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", +} + +# nj_property_tax_relief has no formula before 2026, so the household list starts +# then, while the SPM's undated list carries it in every year. +NJ_RELIEF_START = 2026 + +# 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) + if year < NJ_RELIEF_START: + spm_extra.discard("nj_property_tax_relief") + 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=40, 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=2, 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: From 88e6aece770c72e5274066a6d8854bc3774d8824 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 17:40:27 -0500 Subject: [PATCH 2/4] Use two adults in the Alaska energy relief case A low-burden case keeps other benefits out of the household total, so the expected value is the energy relief alone. Co-Authored-By: Claude Opus 5.5 --- .../income/household/household_benefits.yaml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 bcaccf83f4f..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 @@ -116,19 +116,22 @@ absolute_error_margin: 0.01 input: people: - parent: + adult1: age: 40 employment_income: 500_000 - child: - age: 15 + adult2: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult1, adult2] spm_units: spm_unit: - members: [parent, child] + members: [adult1, adult2] households: household: - members: [parent, child] + members: [adult1, adult2] state_code: AK - household_health_benefits: 0 output: ak_energy_relief: [298.17, 298.17] household_benefits: 2 * 298.17 From 5026fadcce5eab784d0c53933a515ac42efccc1d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 02:20:52 -0500 Subject: [PATCH 3/4] Count Alaska energy relief in the ECPA and BOOST SPM lists Review found both reforms added it to their household lists alone, so SPM resources fell .34 for an Alaska couple in 2024. The cash guard now covers ak_energy_relief and ny_drive_clean_rebate on the SPM side, and the parity test builds fewer simulations. Co-Authored-By: Claude Opus 5.5 --- .../boost/boost_middle_class_tax_credit.py | 1 + .../congress/tlaib/end_child_poverty_act.py | 1 + ...y_relief_in_reform_benefit_aggregates.yaml | 60 +++++++++++++++++++ .../tests/test_aggregate_list_integrity.py | 2 + .../test_household_spm_resource_parity.py | 7 ++- 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/tests/policy/reform/ak_energy_relief_in_reform_benefit_aggregates.yaml 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 095fe62ed6e..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 @@ -139,6 +139,7 @@ def formula(spm_unit, period, parameters): "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", ] 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 844b5d6d8b1..7dea7b0defc 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -167,6 +167,7 @@ def formula(spm_unit, period, parameters): "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/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 38364ede124..22287ba0703 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -237,12 +237,14 @@ def test_reform_aggregates_keep_baseline_contrib_entries( # 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", diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index fdde8776c3e..fca910bdfdc 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -14,6 +14,9 @@ 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 @@ -171,7 +174,7 @@ def total(simulation, names, year): HOUSEHOLD_RESOURCES = ("household_market_income", "household_benefits") -@settings(max_examples=40, deadline=None) +@settings(max_examples=20, deadline=None) @given( payment=st.sampled_from(CASH_PAYMENTS), amount=st.integers(min_value=1, max_value=30_000), @@ -190,7 +193,7 @@ def test_a_dollar_of_cash_adds_a_dollar_to_both_totals(payment, amount): # Random states would miss the few that pay these programs, so every state runs. @pytest.mark.parametrize("state", STATES) -@settings(max_examples=2, deadline=None) +@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): From 0d4a7c369ce16fffecf5d3ca9d28faa025390e8f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 03:22:39 -0500 Subject: [PATCH 4/4] Count New Jersey property tax relief in every household block The household list started in 2026 while the SPM list has no dates, so a value entered for an earlier year reached one total only. Drop the exception the parity test carried for it. Co-Authored-By: Claude Opus 5.5 --- .../household/household_state_benefits.yaml | 6 +++++ .../income/spm_unit/spm_unit_benefits.yaml | 22 +++++++++++++++++++ .../test_household_spm_resource_parity.py | 6 ----- 3 files changed, 28 insertions(+), 6 deletions(-) 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/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 fd1cfe65817..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 @@ -188,6 +188,28 @@ 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: diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index fca910bdfdc..901c44a04c7 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -66,10 +66,6 @@ "assigned_aca_ptc": "only under the flat tax reform's deduct_ptc switch", } -# nj_property_tax_relief has no formula before 2026, so the household list starts -# then, while the SPM's undated list carries it in every year. -NJ_RELIEF_START = 2026 - # Sources in the household list that person market_income still lacks. # #9919 tracks these. MARKET_HOUSEHOLD_ONLY = { @@ -136,8 +132,6 @@ def test_benefit_lists_differ_only_where_documented(year): household_extra = household - spm - set(HOUSEHOLD_ONLY) spm_extra = spm - household - set(SPM_ONLY) - if year < NJ_RELIEF_START: - spm_extra.discard("nj_property_tax_relief") assert household_extra == set(), f"{year}: only in household: {household_extra}" assert spm_extra == set(), f"{year}: only in SPM: {spm_extra}"