From e31bc0e6fc8202909b5cca41d5b391daa53e4f82 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 12:00:00 -0500 Subject: [PATCH 01/12] Net child care subsidies from the SPM expense in every state The SPM subtracts what parents paid for child care and lists no child care benefit among its noncash resources. PolicyEngine subtracted pre-subsidy expenses and added only the Colorado, Washington and West Virginia subsidies to resources, so a subsidy in the other 48 states lowered SPM resources. Net child_care_subsidies from the expense instead, and take the three states out of spm_unit_benefits and the four reform copies of that list. Co-Authored-By: Claude Opus 5.5 --- changelog.d/spm-child-care-subsidies.fixed.md | 1 + .../tlaib/boost/boost_middle_class_tax_credit.py | 1 - .../edaa_end_child_poverty_act.py | 1 - .../congress/tlaib/end_child_poverty_act.py | 1 - .../reforms/states/tx/rebate/tx_rebate.py | 5 ----- .../spm_unit_capped_work_childcare_expenses.py | 14 ++++++++++++-- .../household/income/spm_unit/spm_unit_benefits.py | 7 ++----- 7 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 changelog.d/spm-child-care-subsidies.fixed.md diff --git a/changelog.d/spm-child-care-subsidies.fixed.md b/changelog.d/spm-child-care-subsidies.fixed.md new file mode 100644 index 00000000000..3761ac3df12 --- /dev/null +++ b/changelog.d/spm-child-care-subsidies.fixed.md @@ -0,0 +1 @@ +Count every state child care subsidy in the Supplemental Poverty Measure by netting it from the SPM child care expense. 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..939e41d3330 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 @@ -107,7 +107,6 @@ def formula(spm_unit, period, parameters): # California programs. "ca_cvrp", # California Clean Vehicle Rebate Project. # Colorado programs. - "co_ccap_subsidy", "co_state_supplement", "co_oap", "snap", 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..1dbc0b9ebb7 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 @@ -119,7 +119,6 @@ def formula(spm_unit, period, parameters): "ssi", "ma_state_supplement", "ca_cvrp", - "co_ccap_subsidy", "co_state_supplement", "co_oap", "snap", 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..e8df22a6c4b 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -135,7 +135,6 @@ def formula(spm_unit, period, parameters): # California programs. "ca_cvrp", # California Clean Vehicle Rebate Project. # Colorado programs. - "co_ccap_subsidy", "co_state_supplement", "co_oap", "snap", diff --git a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py index 4d5d1f4c86a..b2b47519ac3 100644 --- a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py +++ b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py @@ -77,19 +77,14 @@ def formula(spm_unit, period, parameters): # 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", diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py index f29e642e20b..b457ab7680a 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py @@ -7,9 +7,19 @@ class spm_unit_capped_work_childcare_expenses(Variable): label = "SPM unit work and childcare expenses" definition_period = YEAR unit = USD + reference = "https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf#page=17" def formula_2024(spm_unit, period, parameters): work_expenses = spm_unit("spm_unit_work_expenses", period) - childcare_expenses = spm_unit("spm_unit_pre_subsidy_childcare_expenses", period) + pre_subsidy_expenses = spm_unit( + "spm_unit_pre_subsidy_childcare_expenses", period + ) + # The SPM subtracts what parents paid for child care, and child care + # is not among its noncash benefits, so a subsidy lowers this expense + # rather than adding to resources. This reads the SPM unit's expenses + # directly because childcare_expenses divides them among children and + # drops them from a unit with no child. + subsidies = spm_unit("child_care_subsidies", period) + childcare_expenses = max_(pre_subsidy_expenses - subsidies, 0) earned_cap = spm_unit("spm_unit_head_spouse_earned_cap", period) - return min_(work_expenses + np.maximum(childcare_expenses, 0), earned_cap) + return min_(work_expenses + childcare_expenses, earned_cap) 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..d26c86ab5d9 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,6 +9,8 @@ class spm_unit_benefits(Variable): unit = USD def formula(spm_unit, period, parameters): + # Child care subsidies stay out of this list: they lower the expense + # in spm_unit_capped_work_childcare_expenses instead. BENEFITS = [ "social_security", "ssi", @@ -58,19 +60,14 @@ def formula(spm_unit, period, parameters): # 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", From ac341ab25d7c45c3c66c389f12a29a0865340825 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 12:25:29 -0500 Subject: [PATCH 02/12] Test child care subsidies in the SPM Add YAML cases for the formula's edges and for New York, Texas, Alaska, Washington, West Virginia and Colorado through their real programs, and property tests for the invariants. The whole-model form of "a subsidy never lowers SPM resources" fails where SNAP, TANF and credits fall by more than the expense, so the property holds those fixed and one test pins the intended case. Co-Authored-By: Claude Opus 5.5 --- .../spm_child_care_subsidies_integration.yaml | 256 +++++++++++++ ...m_unit_capped_work_childcare_expenses.yaml | 71 ++++ .../test_spm_child_care_subsidy_invariants.py | 351 ++++++++++++++++++ 3 files changed, 678 insertions(+) create mode 100644 policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml create mode 100644 policyengine_us/tests/test_spm_child_care_subsidy_invariants.py diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml new file mode 100644 index 00000000000..f05bf02bb74 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml @@ -0,0 +1,256 @@ +# Real state programs through the SPM child care expense. New York, Texas and +# Alaska subsidies never reached the SPM before; Colorado, Washington and West +# Virginia reached it as resources, on top of pre-subsidy expenses. +# +# Each family is one working parent with earnings of $30,000 and a child aged 3 +# in care 8 hours a day, 5 days a week. Without weeks_worked, work expenses are +# zero, so the SPM expense is the out-of-pocket amount. + +- name: New York, Manhattan family with a subsidy just below the expense + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: NY + county_fips: "36061" + output: + child_care_subsidies: 13_916.40 + # 14,000 - 13,916.40. + spm_unit_capped_work_childcare_expenses: 83.60 + +- name: New York, a child with no reported care hours gets no subsidy, so the expense stays + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: NY + county_fips: "36061" + output: + child_care_subsidies: 0 + spm_unit_capped_work_childcare_expenses: 14_000 + +- name: Texas, a subsidy the SPM left out before + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: TX + output: + child_care_subsidies: 6_014.49 + # 14,000 - 6,014.49. + spm_unit_capped_work_childcare_expenses: 7_985.51 + +- name: Alaska, a subsidy the SPM left out before + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: AK + output: + child_care_subsidies: 9_432 + # 14,000 - 9,432. + spm_unit_capped_work_childcare_expenses: 4_568 + +- name: Washington, a subsidy the SPM counted as a resource before + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: WA + output: + child_care_subsidies: 13_220 + # 14,000 - 13,220. + spm_unit_capped_work_childcare_expenses: 780 + +- name: West Virginia, a subsidy the SPM counted as a resource before + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: WV + output: + child_care_subsidies: 7_623 + # 14,000 - 7,623. + spm_unit_capped_work_childcare_expenses: 6_377 + +- name: Colorado, a subsidy the SPM counted as a resource before + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 14_000 + households: + household: + members: [parent, child] + state_code: CO + output: + child_care_subsidies: 12_613.16 + # 14,000 - 12,613.16. + spm_unit_capped_work_childcare_expenses: 1_386.84 diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml index cd2e0be8f63..619b3d891ca 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml @@ -105,3 +105,74 @@ spm_unit_pre_subsidy_childcare_expenses: 2_000 output: spm_unit_capped_work_childcare_expenses: 2_000 + +- name: A child care subsidy lowers the out-of-pocket expense + period: 2026 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 30_000 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 13_916.40 + output: + # 1,000 + (14,000 - 13,916.40). + spm_unit_capped_work_childcare_expenses: 1_083.60 + +- name: A subsidy above the expense leaves only work expenses + period: 2026 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 30_000 + spm_unit_pre_subsidy_childcare_expenses: 1_200 + child_care_subsidies: 22_828 + output: + # The floor keeps the part of the subsidy above the expense out of the + # SPM, so it cannot offset work expenses. + spm_unit_capped_work_childcare_expenses: 1_000 + +- name: The earnings cap applies after the subsidy + period: 2026 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 5_000 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 2_000 + output: + # 1,000 + 12,000 exceeds the cap, so the subsidy leaves the expense at + # the cap. + spm_unit_capped_work_childcare_expenses: 5_000 + +- name: A subsidy can bring the expense below the earnings cap + period: 2026 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 5_000 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 13_000 + output: + spm_unit_capped_work_childcare_expenses: 2_000 + +- name: Expenses in a unit with no child are capped as before and take no subsidy + period: 2026 + absolute_error_margin: 0.001 + input: + people: + head: + age: 46 + employment_income: 9_000 + is_household_head: true + is_tax_unit_head: true + tax_units: + tax_unit: + members: [head] + spm_units: + spm_unit: + members: [head] + spm_unit_work_expenses: 0 + spm_unit_pre_subsidy_childcare_expenses: 4_000 + output: + child_care_subsidies: 0 + spm_unit_capped_work_childcare_expenses: 4_000 diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py new file mode 100644 index 00000000000..0e152dc6ad9 --- /dev/null +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -0,0 +1,351 @@ +"""Invariants for child care subsidies in the Supplemental Poverty Measure. + +The Census Bureau's SPM technical documentation (last updated 2026-09-15): + +- Page 17 subtracts what parents paid for child care, combined with other work + expenses and capped at the earnings of the lower earner among the reference + person and spouse or partner. +- Section 4.3.1 lists the noncash benefits the SPM adds to resources: SNAP, + school lunch, WIC, energy assistance, internet assistance and housing + assistance. Child care is not among them. + +So a subsidy reaches the SPM once, as a lower expense. With E the pre-subsidy +expense, S the subsidy, W work expenses and C the earnings cap: + + expense(S) = min(W + max(E - S, 0), C) + +For every SPM unit: + +1. The model's expense equals that reference, written from the documentation + rather than from the formula. +2. A subsidy never raises the expense and lowers it by at most the subsidy: + 0 <= expense(0) - expense(S) <= S. A larger subsidy never raises it either. +3. No double counting. With taxes and every other benefit held fixed, a + subsidy leaves spm_unit_benefits unchanged and raises spm_unit_net_income + by exactly expense(0) - expense(S). So this direct effect is never negative + and never exceeds the subsidy. +4. A subsidy of zero leaves the expense at min(W + max(E, 0), C), the formula + before this rule, in every state. +5. No program is counted twice. No *_child_care_subsidies variable, and no + program one of them adds, appears in spm_unit_benefits or in a reform's copy + of that list, and all of them reach child_care_subsidies. +6. Differential. With real programs turned on and off, the SPM and household + measures agree on what else changed: the change in SPM net income is the + change in household net income, less the subsidy the household measure + counts, plus the fall in the expense. + +Intended violation of "a subsidy never lowers SPM resources". Property 3 holds +taxes and other benefits fixed, and it has to. SNAP, TANF and tax credits read +out-of-pocket expenses, so a subsidy can cut them by more than the SPM expense +falls, most plainly when the unit stays at the earnings cap and the expense +does not move at all. The household measure shows the same interaction. +test_subsidy_at_the_cap_can_lower_spm_income pins one such case; the fall comes +from those programs and not from this rule. +""" + +from functools import cache + +import numpy as np +import pytest +import yaml + +from policyengine_core.reforms import Reform +from policyengine_us import Simulation +from policyengine_us.system import CountryTaxBenefitSystem +from test_aggregate_list_integrity import ( + BASELINE_SPM_BENEFITS_SOURCE, + PACKAGE, + REFORM_AGGREGATE_OVERRIDES, + benefits_lists_in, + members_of, +) + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") +given, settings = hypothesis.given, hypothesis.settings + +YEAR = 2026 +TOLERANCE = 0.01 +PROGRAMS_LIST = "gov/hhs/ccdf/child_care_subsidy_programs.yaml" +STATE_SUBSIDIES = sorted(members_of(PROGRAMS_LIST)) +STATES = [name[:2].upper() for name in STATE_SUBSIDIES] + + +def reference_expense(pre_subsidy, subsidy, work, cap): + return min(work + max(pre_subsidy - subsidy, 0), cap) + + +def build_units(units): + """One SPM unit per entry. The parent's earnings are high enough that no + means-tested benefit pays, and taxes are an input, so only the child care + rule can move these units' SPM results.""" + people, tax_units, spm_units, families, households = {}, {}, {}, {}, {} + marital_units = {} + for i, unit in enumerate(units): + parent, child = f"parent_{i}", f"child_{i}" + people[parent] = { + "age": {YEAR: 35}, + "employment_income": {YEAR: 400_000}, + "is_tax_unit_head": {YEAR: True}, + "is_household_head": {YEAR: True}, + } + people[child] = {"age": {YEAR: 4}, "is_tax_unit_dependent": {YEAR: True}} + members = [parent, child] + state = unit["state"] + tax_units[f"tax_unit_{i}"] = {"members": members} + families[f"family_{i}"] = {"members": members} + marital_units[f"marital_unit_{i}"] = {"members": [parent]} + marital_units[f"child_marital_unit_{i}"] = {"members": [child]} + spm_units[f"spm_unit_{i}"] = { + "members": members, + "spm_unit_pre_subsidy_childcare_expenses": {YEAR: unit["expenses"]}, + "spm_unit_work_expenses": {YEAR: unit["work"]}, + "spm_unit_head_spouse_earned_cap": {YEAR: unit["cap"]}, + f"{state.lower()}_child_care_subsidies": {YEAR: unit["subsidy"]}, + "spm_unit_taxes": {YEAR: 0}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: state}, + } + return { + "people": people, + "tax_units": tax_units, + "families": families, + "marital_units": marital_units, + "spm_units": spm_units, + "households": households, + } + + +amounts = st.integers(0, 40_000) +unit_strategy = st.fixed_dictionaries( + { + "state": st.sampled_from(STATES), + "expenses": amounts, + "subsidy": amounts, + "work": st.integers(0, 6_000), + "cap": st.integers(0, 60_000), + "extra_subsidy": st.integers(0, 10_000), + } +) + + +def spm_outputs(units): + simulation = Simulation(situation=build_units(units)) + return { + variable: np.asarray(simulation.calculate(variable, YEAR), dtype=float) + for variable in ( + "child_care_subsidies", + "spm_unit_capped_work_childcare_expenses", + "spm_unit_benefits", + "spm_unit_net_income", + ) + } + + +@settings(max_examples=25, deadline=None) +@given(st.lists(unit_strategy, min_size=1, max_size=4)) +def test_expense_nets_subsidies_exactly_once(units): + # Each unit runs three times in one simulation: as drawn, with no subsidy + # and with a larger subsidy. + without = [{**unit, "subsidy": 0} for unit in units] + larger = [{**u, "subsidy": u["subsidy"] + u["extra_subsidy"]} for u in units] + results = spm_outputs(units + without + larger) + expense = results["spm_unit_capped_work_childcare_expenses"] + benefits = results["spm_unit_benefits"] + net_income = results["spm_unit_net_income"] + + n = len(units) + for i, unit in enumerate(units): + reference = reference_expense( + unit["expenses"], unit["subsidy"], unit["work"], unit["cap"] + ) + no_subsidy = reference_expense(unit["expenses"], 0, unit["work"], unit["cap"]) + # 1. The expense matches the documented definition. + assert expense[i] == pytest.approx(reference, abs=TOLERANCE) + assert results["child_care_subsidies"][i] == pytest.approx( + unit["subsidy"], abs=TOLERANCE + ) + # 4. Without a subsidy the old formula still holds, in every state. + assert expense[n + i] == pytest.approx(no_subsidy, abs=TOLERANCE) + # 2. The fall is at least zero and at most the subsidy, and a larger + # subsidy never raises the expense. + fall = expense[n + i] - expense[i] + assert -TOLERANCE <= fall <= unit["subsidy"] + TOLERANCE + assert expense[2 * n + i] <= expense[i] + TOLERANCE + # 3. The subsidy is not a resource, so net income rises by the fall in + # the expense and by nothing else. + assert benefits[i] == pytest.approx(benefits[n + i], abs=TOLERANCE) + gain = net_income[i] - net_income[n + i] + assert gain == pytest.approx(fall, abs=TOLERANCE) + + +def state_subsidy_adds(): + """{*_child_care_subsidies: the programs it adds}, from the model.""" + from policyengine_us.system import system + + return {name: list(system.variables[name].adds) for name in STATE_SUBSIDIES} + + +def test_no_subsidy_is_a_spm_resource(): + subsidy_variables = set(STATE_SUBSIDIES) | {"child_care_subsidies"} + for programs in state_subsidy_adds().values(): + subsidy_variables.update(programs) + lists = { + "baseline spm_unit_benefits": benefits_lists_in(BASELINE_SPM_BENEFITS_SOURCE)[ + 0 + ][1] + } + for path, variable, members in REFORM_AGGREGATE_OVERRIDES: + if variable == "spm_unit_benefits": + lists[path] = members + assert len(lists) >= 5, f"expected the reform copies, found {sorted(lists)}" + counted_twice = { + name: sorted(subsidy_variables & set(members)) + for name, members in lists.items() + if subsidy_variables & set(members) + } + assert not counted_twice, ( + "Child care subsidies reach the SPM by lowering " + f"spm_unit_capped_work_childcare_expenses, not as resources: {counted_twice}" + ) + + +def test_every_state_subsidy_reaches_the_spm_expense(): + variables_dir = PACKAGE / "variables/gov/states" + defined = {path.stem for path in variables_dir.rglob("*_child_care_subsidies.py")} + latest_block = max( + yaml.load( + (PACKAGE / "parameters" / PROGRAMS_LIST).read_text(), yaml.BaseLoader + )["values"].items() + )[1] + assert defined == set(latest_block) + assert len(defined) == 51 + + +class NoSubsidies(Reform): + def apply(self): + for variable in STATE_SUBSIDIES: + self.neutralize_variable(variable) + + +@cache +def no_subsidy_system(): + return NoSubsidies(CountryTaxBenefitSystem()) + + +def family(state, earnings, expenses): + """A working parent and a child aged 3 in full-time care, with real + programs, taxes and benefits.""" + members = ["parent", "child"] + return { + "people": { + "parent": { + "age": {YEAR: 30}, + "employment_income": {YEAR: earnings}, + "weekly_hours_worked": {YEAR: 40}, + "weeks_worked": {YEAR: 52}, + "is_tax_unit_head": {YEAR: True}, + "is_household_head": {YEAR: True}, + }, + "child": { + "age": {YEAR: 3}, + "childcare_hours_per_day": {YEAR: 8}, + "childcare_days_per_week": {YEAR: 5}, + "childcare_attending_days_per_month": {YEAR: 21}, + "is_tax_unit_dependent": {YEAR: True}, + }, + }, + "tax_units": {"tax_unit": {"members": members}}, + "families": {"family": {"members": members}}, + "spm_units": { + "spm_unit": { + "members": members, + "spm_unit_pre_subsidy_childcare_expenses": {YEAR: expenses}, + "meets_ccdf_activity_test": {YEAR: True}, + } + }, + "households": {"household": {"members": members, "state_code": {YEAR: state}}}, + } + + +def with_and_without_subsidies(state, earnings, expenses): + situation = family(state, earnings, expenses) + on = Simulation(situation=situation) + off = Simulation(situation=situation, tax_benefit_system=no_subsidy_system()) + + def change(variable): + return float(on.calculate(variable, YEAR).sum()) - float( + off.calculate(variable, YEAR).sum() + ) + + assert float(off.calculate("child_care_subsidies", YEAR).sum()) == 0 + return on, change + + +def subsidy_counted_by_household_measure(simulation, state): + """The subsidy household_state_benefits sums, whether it lists the state's + variable or the national aggregate.""" + listed = simulation.tax_benefit_system.parameters( + f"{YEAR}-01-01" + ).gov.household.household_state_benefits + state_variable = f"{state.lower()}_child_care_subsidies" + if "child_care_subsidies" in listed or state_variable in listed: + return float(simulation.calculate("child_care_subsidies", YEAR).sum()) + return 0.0 + + +@settings(max_examples=12, deadline=None) +@given( + state=st.sampled_from(STATES), + earnings=st.integers(5_000, 60_000), + expenses=st.integers(0, 25_000), +) +def test_spm_and_household_measures_agree(state, earnings, expenses): + on, change = with_and_without_subsidies(state, earnings, expenses) + fall = -change("spm_unit_capped_work_childcare_expenses") + counted = subsidy_counted_by_household_measure(on, state) + assert change("spm_unit_net_income") == pytest.approx( + change("household_net_income") - counted + fall, abs=TOLERANCE + ) + + +def test_new_york_subsidy_raises_both_measures_by_the_same_amount(): + # The Manhattan family from the report that prompted this rule. + situation = family("NY", 30_000, 14_000) + situation["households"]["household"]["county_fips"] = {YEAR: "36061"} + del situation["people"]["parent"]["weeks_worked"] + on = Simulation(situation=situation) + off = Simulation(situation=situation, tax_benefit_system=no_subsidy_system()) + + def change(variable): + return float(on.calculate(variable, YEAR).sum()) - float( + off.calculate(variable, YEAR).sum() + ) + + subsidy = float(on.calculate("child_care_subsidies", YEAR).sum()) + assert subsidy > 13_000 + assert change("spm_unit_capped_work_childcare_expenses") == pytest.approx( + -subsidy, abs=TOLERANCE + ) + assert change("household_net_income") > 0 + assert change("spm_unit_net_income") == pytest.approx( + change("household_net_income"), abs=TOLERANCE + ) + + +def test_subsidy_at_the_cap_can_lower_spm_income(): + # Intended: this unit stays at the earnings cap, so the SPM expense does + # not move, while the fall in out-of-pocket expenses cuts SNAP. See the + # module docstring. + on, change = with_and_without_subsidies("CA", 8_000, 25_000) + assert float(on.calculate("child_care_subsidies", YEAR).sum()) > 0 + assert change("spm_unit_capped_work_childcare_expenses") == pytest.approx( + 0, abs=TOLERANCE + ) + assert change("snap") < 0 + assert change("spm_unit_net_income") == pytest.approx( + change("spm_unit_benefits") - change("spm_unit_taxes"), abs=TOLERANCE + ) + assert change("spm_unit_net_income") < 0 From 343f8fee08195de402bb6936b9986f2bab55b7d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 13:11:08 -0500 Subject: [PATCH 03/12] Pin the size of the SPM expense fall in the differential test The differential passed on the old code because its identity holds when the expense does not move. It now also requires the fall the Census definition gives, with explicit examples in states that left the subsidy out and states that counted it as a resource. Income comparisons get a tolerance above float32 rounding, which tripped the exact comparison at the fixture's $400,000 of earnings. Co-Authored-By: Claude Opus 5.5 --- .../test_spm_child_care_subsidy_invariants.py | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index 0e152dc6ad9..9aa0dbbde69 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -29,10 +29,11 @@ 5. No program is counted twice. No *_child_care_subsidies variable, and no program one of them adds, appears in spm_unit_benefits or in a reform's copy of that list, and all of them reach child_care_subsidies. -6. Differential. With real programs turned on and off, the SPM and household - measures agree on what else changed: the change in SPM net income is the - change in household net income, less the subsidy the household measure - counts, plus the fall in the expense. +6. Differential. With real programs turned on and off, the expense falls by + the amount the reference gives for the subsidy the program paid, and the + SPM and household measures agree on what else changed: the change in SPM + net income is the change in household net income, less the subsidy the + household measure counts, plus the fall in the expense. Intended violation of "a subsidy never lowers SPM resources". Property 3 holds taxes and other benefits fixed, and it has to. SNAP, TANF and tax credits read @@ -62,10 +63,14 @@ hypothesis = pytest.importorskip("hypothesis") st = pytest.importorskip("hypothesis.strategies") -given, settings = hypothesis.given, hypothesis.settings +example, given, settings = hypothesis.example, hypothesis.given, hypothesis.settings YEAR = 2026 TOLERANCE = 0.01 +# Income variables are float32, which rounds to about $0.03 at the $400,000 the +# fixtures below earn. Every input is a whole dollar, so this still catches a +# $1 error. +INCOME_TOLERANCE = 0.25 PROGRAMS_LIST = "gov/hhs/ccdf/child_care_subsidy_programs.yaml" STATE_SUBSIDIES = sorted(members_of(PROGRAMS_LIST)) STATES = [name[:2].upper() for name in STATE_SUBSIDIES] @@ -178,7 +183,7 @@ def test_expense_nets_subsidies_exactly_once(units): # the expense and by nothing else. assert benefits[i] == pytest.approx(benefits[n + i], abs=TOLERANCE) gain = net_income[i] - net_income[n + i] - assert gain == pytest.approx(fall, abs=TOLERANCE) + assert gain == pytest.approx(fall, abs=INCOME_TOLERANCE) def state_subsidy_adds(): @@ -212,7 +217,7 @@ def test_no_subsidy_is_a_spm_resource(): ) -def test_every_state_subsidy_reaches_the_spm_expense(): +def test_every_state_subsidy_is_in_the_programs_list(): variables_dir = PACKAGE / "variables/gov/states" defined = {path.stem for path in variables_dir.rglob("*_child_care_subsidies.py")} latest_block = max( @@ -270,8 +275,7 @@ def family(state, earnings, expenses): } -def with_and_without_subsidies(state, earnings, expenses): - situation = family(state, earnings, expenses) +def with_and_without_subsidies(situation): on = Simulation(situation=situation) off = Simulation(situation=situation, tax_benefit_system=no_subsidy_system()) @@ -302,12 +306,28 @@ def subsidy_counted_by_household_measure(simulation, state): earnings=st.integers(5_000, 60_000), expenses=st.integers(0, 25_000), ) +# States that left the subsidy out before and states that counted it as a +# resource, at amounts where each program pays. +@example("NY", 30_000, 14_000) +@example("TX", 30_000, 14_000) +@example("AK", 30_000, 14_000) +@example("WA", 30_000, 14_000) +@example("WV", 30_000, 14_000) +@example("CO", 30_000, 14_000) def test_spm_and_household_measures_agree(state, earnings, expenses): - on, change = with_and_without_subsidies(state, earnings, expenses) + on, change = with_and_without_subsidies(family(state, earnings, expenses)) + subsidy = float(on.calculate("child_care_subsidies", YEAR).sum()) + work = float(on.calculate("spm_unit_work_expenses", YEAR).sum()) + cap = float(on.calculate("spm_unit_head_spouse_earned_cap", YEAR).sum()) fall = -change("spm_unit_capped_work_childcare_expenses") + assert fall == pytest.approx( + reference_expense(expenses, 0, work, cap) + - reference_expense(expenses, subsidy, work, cap), + abs=TOLERANCE, + ) counted = subsidy_counted_by_household_measure(on, state) assert change("spm_unit_net_income") == pytest.approx( - change("household_net_income") - counted + fall, abs=TOLERANCE + change("household_net_income") - counted + fall, abs=INCOME_TOLERANCE ) @@ -316,14 +336,7 @@ def test_new_york_subsidy_raises_both_measures_by_the_same_amount(): situation = family("NY", 30_000, 14_000) situation["households"]["household"]["county_fips"] = {YEAR: "36061"} del situation["people"]["parent"]["weeks_worked"] - on = Simulation(situation=situation) - off = Simulation(situation=situation, tax_benefit_system=no_subsidy_system()) - - def change(variable): - return float(on.calculate(variable, YEAR).sum()) - float( - off.calculate(variable, YEAR).sum() - ) - + on, change = with_and_without_subsidies(situation) subsidy = float(on.calculate("child_care_subsidies", YEAR).sum()) assert subsidy > 13_000 assert change("spm_unit_capped_work_childcare_expenses") == pytest.approx( @@ -331,7 +344,7 @@ def change(variable): ) assert change("household_net_income") > 0 assert change("spm_unit_net_income") == pytest.approx( - change("household_net_income"), abs=TOLERANCE + change("household_net_income"), abs=INCOME_TOLERANCE ) @@ -339,13 +352,13 @@ def test_subsidy_at_the_cap_can_lower_spm_income(): # Intended: this unit stays at the earnings cap, so the SPM expense does # not move, while the fall in out-of-pocket expenses cuts SNAP. See the # module docstring. - on, change = with_and_without_subsidies("CA", 8_000, 25_000) + on, change = with_and_without_subsidies(family("CA", 8_000, 25_000)) assert float(on.calculate("child_care_subsidies", YEAR).sum()) > 0 assert change("spm_unit_capped_work_childcare_expenses") == pytest.approx( 0, abs=TOLERANCE ) assert change("snap") < 0 assert change("spm_unit_net_income") == pytest.approx( - change("spm_unit_benefits") - change("spm_unit_taxes"), abs=TOLERANCE + change("spm_unit_benefits") - change("spm_unit_taxes"), abs=INCOME_TOLERANCE ) assert change("spm_unit_net_income") < 0 From 606699d6406a51ccef8809f9ab96701634dbfb12 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:36:05 -0500 Subject: [PATCH 04/12] Close the review's gaps in the SPM child care rule Reviews by GPT-6.1 Sol and Opus 5.5 found three problems at 343f8fee08. - Before 2024 the SPM expense has no formula, so removing the Colorado, Washington and West Virginia subsidies from resources made them reach no SPM path. A 2023 Washington subsidy lowered SPM income. Those three now stay resources until 2024, and years before 2024 match main. - The 2024 program block left out the DC, Tennessee and Texas subsidies, which pay in 2024. All 51 now start at 2024, the first year the SPM expense reads the list, and the test checks every block from then on. - The formula read the pre-subsidy input and ignored a direct out-of-pocket amount. It now reads childcare_expenses, the model's out-of-pocket variable, for a unit with a child. Also add paying-subsidy cases for 2024 and 2025, correct the intended violation's wording, record that datasets fill the pre-subsidy input from reported spending, and route the property tests to the group for retained full systems. Co-Authored-By: Claude Opus 5.5 --- Makefile | 3 +- changelog.d/spm-child-care-subsidies.fixed.md | 2 +- .../hhs/ccdf/child_care_subsidy_programs.yaml | 49 -------- .../spm_child_care_subsidies_integration.yaml | 108 ++++++++++++++++++ ...m_unit_capped_work_childcare_expenses.yaml | 81 +++++++++++++ .../income/spm_unit/spm_unit_benefits.yaml | 46 ++++++++ .../test_spm_child_care_subsidy_invariants.py | 37 +++--- ...spm_unit_capped_work_childcare_expenses.py | 17 ++- .../income/spm_unit/spm_unit_benefits.py | 14 ++- 9 files changed, 286 insertions(+), 71 deletions(-) diff --git a/Makefile b/Makefile index 056dcb663a5..f431d31b2e4 100644 --- a/Makefile +++ b/Makefile @@ -91,13 +91,14 @@ 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 -# The first four files retain three independent full systems (the two CTC +# The first five files retain four 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. REST_REMAINING_HEAVY_TESTS := $(TESTS)/test_niit_section_911_magi.py \ $(TESTS)/test_ctc_foreign_earned_income_exclusion.py \ $(TESTS)/test_ctc_foreign_earned_income_exclusion_property.py \ $(TESTS)/unit/test_medicaid_abd_ssi_rate_standard_properties.py \ + $(TESTS)/test_spm_child_care_subsidy_invariants.py \ $(TESTS)/test_capital_gain_distributions_loss_limit.py \ $(TESTS)/test_schedule_d_filer_net_capital_gain.py REST_LISTED_TESTS := $(REST_CACHED_ARRAY_TESTS) $(REST_HEAVY_A_TESTS) $(REST_HEAVY_B_TESTS) \ diff --git a/changelog.d/spm-child-care-subsidies.fixed.md b/changelog.d/spm-child-care-subsidies.fixed.md index 3761ac3df12..090ad31ef85 100644 --- a/changelog.d/spm-child-care-subsidies.fixed.md +++ b/changelog.d/spm-child-care-subsidies.fixed.md @@ -1 +1 @@ -Count every state child care subsidy in the Supplemental Poverty Measure by netting it from the SPM child care expense. +Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. diff --git a/policyengine_us/parameters/gov/hhs/ccdf/child_care_subsidy_programs.yaml b/policyengine_us/parameters/gov/hhs/ccdf/child_care_subsidy_programs.yaml index 23ed336402b..6461d03490d 100644 --- a/policyengine_us/parameters/gov/hhs/ccdf/child_care_subsidy_programs.yaml +++ b/policyengine_us/parameters/gov/hhs/ccdf/child_care_subsidy_programs.yaml @@ -96,58 +96,9 @@ values: - nc_child_care_subsidies # North Carolina Subsidized Child Care Assistance - wy_child_care_subsidies # Wyoming Child Care, Purchase of Service - ny_child_care_subsidies # New York Child Care Assistance Program - 2025-01-01: - - ak_child_care_subsidies # Alaska Child Care Assistance Program (PASS) - - ar_child_care_subsidies # Arkansas School Readiness Assistance - - al_child_care_subsidies # Alabama Child Care Subsidy Program - - az_child_care_subsidies # Arizona Child Care Assistance Program - - ca_child_care_subsidies # California Child Care - - co_child_care_subsidies # Colorado Child Care Assistance Program - - ct_child_care_subsidies # Connecticut Care 4 Kids - - de_child_care_subsidies # Delaware Purchase of Care - - fl_child_care_subsidies # Florida School Readiness Program (SR) - - ga_child_care_subsidies # Georgia Childcare and Parent Services - - hi_child_care_subsidies # Hawaii Child Care Subsidy - - ia_child_care_subsidies # Iowa Child Care Assistance - - id_child_care_subsidies # Idaho Child Care Program - - il_child_care_subsidies # Illinois Child Care Assistance Program - - in_child_care_subsidies # Indiana Child Care and Development Fund Voucher Program - - ks_child_care_subsidies # Kansas Child Care Assistance Program - - ky_child_care_subsidies # Kentucky Child Care Assistance Program - - la_child_care_subsidies # Louisiana Child Care Assistance Program - - ma_child_care_subsidies # Massachusetts Child Care Financial Assistance - - md_child_care_subsidies # Maryland Child Care Scholarship - - me_child_care_subsidies # Maine Child Care Affordability Program - - mi_child_care_subsidies # Michigan Child Development and Care program - - mn_child_care_subsidies # Minnesota Child Care Assistance Program - - mo_child_care_subsidies # Missouri Child Care Subsidy - - ms_child_care_subsidies # Mississippi Child Care Payment Program (CCPP) - - mt_child_care_subsidies # Montana Best Beginnings Child Care Scholarship - - ne_child_care_subsidies # Nebraska Child Care Subsidy - - nm_child_care_subsidies # New Mexico Child Care Assistance Program - - vt_child_care_subsidies # Vermont Child Care Financial Assistance Program - - nh_child_care_subsidies # New Hampshire Child Care Scholarship Program - - pa_child_care_subsidies # Pennsylvania Child Care Works - - nj_child_care_subsidies # New Jersey Child Care Assistance Program - - oh_child_care_subsidies # Ohio Publicly Funded Child Care - - nv_child_care_subsidies # Nevada Child Care and Development Program (CCDP) - - or_child_care_subsidies # Oregon Employment Related Day Care - - ri_child_care_subsidies # Rhode Island Child Care Assistance Program - - sc_child_care_subsidies # South Carolina Child Care Scholarship Program - - ut_child_care_subsidies # Utah Child Care Assistance (CC) - - va_child_care_subsidies # Virginia Child Care Subsidy Program - - wa_child_care_subsidies # Washington Working Connections Child Care - - wi_child_care_subsidies # Wisconsin Shares Child Care Subsidy Program - - wv_child_care_subsidies # West Virginia Child Care Assistance Program - - nd_child_care_subsidies # North Dakota Child Care Assistance Program - - ok_child_care_subsidies # Oklahoma Child Care Subsidy Program - - sd_child_care_subsidies # South Dakota Child Care Assistance (CCA) - - nc_child_care_subsidies # North Carolina Subsidized Child Care Assistance - dc_child_care_subsidies # DC Child Care Subsidy Program - tn_child_care_subsidies # Tennessee Child Care Certificate Program (Smart Steps) - - wy_child_care_subsidies # Wyoming Child Care, Purchase of Service - tx_child_care_subsidies # Texas Child Care Services (CCS) - - ny_child_care_subsidies # New York Child Care Assistance Program metadata: unit: list diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml index f05bf02bb74..04e331f1380 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml @@ -254,3 +254,111 @@ child_care_subsidies: 12_613.16 # 14,000 - 12,613.16. spm_unit_capped_work_childcare_expenses: 1_386.84 + +- name: District of Columbia, a program the 2024 list left out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 24_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 20_000 + households: + household: + members: [parent, child] + state_code: DC + output: + child_care_subsidies: 15_876 + # 20,000 - 15,876. + spm_unit_capped_work_childcare_expenses: 4_124 + +- name: Tennessee, paying from October 2024 + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 24_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 20_000 + households: + household: + members: [parent, child] + state_code: TN + output: + child_care_subsidies: 2_704 + # 20,000 - 2,704. + spm_unit_capped_work_childcare_expenses: 17_296 + +- name: Texas, a program the 2024 list left out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 24_000 + weekly_hours_worked_before_lsr: 40 + is_tax_unit_head: true + is_household_head: true + child: + age: 3 + childcare_hours_per_day: 8 + childcare_days_per_week: 5 + childcare_attending_days_per_month: 21 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + families: + family: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 20_000 + households: + household: + members: [parent, child] + state_code: TX + output: + child_care_subsidies: 5_793.01 + # 20,000 - 5,793.01. + spm_unit_capped_work_childcare_expenses: 14_206.99 diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml index 619b3d891ca..0c7d4b1ce85 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml @@ -176,3 +176,84 @@ output: child_care_subsidies: 0 spm_unit_capped_work_childcare_expenses: 4_000 + +- name: A subsidy in a unit with no child still lowers the expense + period: 2026 + absolute_error_margin: 0.001 + input: + people: + head: + age: 46 + employment_income: 9_000 + is_household_head: true + is_tax_unit_head: true + tax_units: + tax_unit: + members: [head] + spm_units: + spm_unit: + members: [head] + spm_unit_work_expenses: 0 + spm_unit_pre_subsidy_childcare_expenses: 4_000 + child_care_subsidies: 1_500 + output: + spm_unit_capped_work_childcare_expenses: 2_500 + +- name: A direct out-of-pocket amount replaces the amount net of subsidies + period: 2026 + absolute_error_margin: 0.001 + input: + people: + head: + age: 30 + employment_income: 30_000 + is_household_head: true + is_tax_unit_head: true + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + spm_units: + spm_unit: + members: [head, child] + spm_unit_work_expenses: 0 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 10_000 + childcare_expenses: 6_000 + output: + spm_unit_capped_work_childcare_expenses: 6_000 + +- name: A subsidy lowers the expense in 2024, the formula's first year + period: 2024 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 50_000 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 2_000 + output: + spm_unit_capped_work_childcare_expenses: 13_000 + +- name: A subsidy lowers the expense in 2025 + period: 2025 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 50_000 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 2_000 + output: + spm_unit_capped_work_childcare_expenses: 13_000 + +- name: Before 2024 the expense has no formula, so a subsidy cannot lower it + period: 2023 + absolute_error_margin: 0.001 + input: + spm_unit_work_expenses: 1_000 + spm_unit_head_spouse_earned_cap: 50_000 + spm_unit_pre_subsidy_childcare_expenses: 14_000 + child_care_subsidies: 2_000 + output: + spm_unit_capped_work_childcare_expenses: 0 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..d92dd58106c 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,49 @@ unemployment_compensation_months: 6 snap: 1_368 spm_unit_benefits: 11_586 + +- name: Before 2024 three child care subsidies stay resources, because the SPM expense has no formula to lower. + period: 2023 + input: + co_ccap_subsidy: 1 + wa_child_care_subsidies: 2 + wv_child_care_subsidies: 4 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 1 + 2 + 4 + +- name: From 2024 child care subsidies leave resources and lower the SPM expense instead. + period: 2024 + input: + co_ccap_subsidy: 1 + wa_child_care_subsidies: 2 + wv_child_care_subsidies: 4 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index 9aa0dbbde69..b61870c1087 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -26,9 +26,11 @@ and never exceeds the subsidy. 4. A subsidy of zero leaves the expense at min(W + max(E, 0), C), the formula before this rule, in every state. -5. No program is counted twice. No *_child_care_subsidies variable, and no - program one of them adds, appears in spm_unit_benefits or in a reform's copy - of that list, and all of them reach child_care_subsidies. +5. No program is counted twice. From 2024, no *_child_care_subsidies variable, + and no program one of them adds, appears in spm_unit_benefits or in a + reform's copy of that list, and all of them reach child_care_subsidies. + Before 2024 the expense has no formula, so Colorado's, Washington's and West + Virginia's subsidies stay resources and still count once. 6. Differential. With real programs turned on and off, the expense falls by the amount the reference gives for the subsidy the program paid, and the SPM and household measures agree on what else changed: the change in SPM @@ -38,10 +40,16 @@ Intended violation of "a subsidy never lowers SPM resources". Property 3 holds taxes and other benefits fixed, and it has to. SNAP, TANF and tax credits read out-of-pocket expenses, so a subsidy can cut them by more than the SPM expense -falls, most plainly when the unit stays at the earnings cap and the expense -does not move at all. The household measure shows the same interaction. -test_subsidy_at_the_cap_can_lower_spm_income pins one such case; the fall comes -from those programs and not from this rule. +falls. A unit that stays at the earnings cap shows this most plainly, because +its expense does not move at all, and a benefit cliff can do it below the cap. +Household net income loses the same benefits but also counts the subsidy, so it +can rise while SPM income falls. test_subsidy_at_the_cap_can_lower_spm_income +pins one such case; the fall comes from those programs and not from this rule. + +These properties read spm_unit_pre_subsidy_childcare_expenses as a pre-subsidy +price, which is what it means in a household calculation. Datasets fill it from +the ASEC's reported spending, so on microdata the rule nets a modeled subsidy +from a reported payment. """ from functools import cache @@ -220,12 +228,15 @@ def test_no_subsidy_is_a_spm_resource(): def test_every_state_subsidy_is_in_the_programs_list(): variables_dir = PACKAGE / "variables/gov/states" defined = {path.stem for path in variables_dir.rglob("*_child_care_subsidies.py")} - latest_block = max( - yaml.load( - (PACKAGE / "parameters" / PROGRAMS_LIST).read_text(), yaml.BaseLoader - )["values"].items() - )[1] - assert defined == set(latest_block) + blocks = yaml.load( + (PACKAGE / "parameters" / PROGRAMS_LIST).read_text(), yaml.BaseLoader + )["values"] + # The SPM expense reads the list from 2024, at the start of each year, so a + # program that starts paying during a year has to be in that year's block. + assert "2024-01-01" in blocks + for start, programs in blocks.items(): + if start >= "2024": + assert defined == set(programs), start assert len(defined) == 51 diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py index b457ab7680a..8776b697086 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py @@ -16,10 +16,17 @@ def formula_2024(spm_unit, period, parameters): ) # The SPM subtracts what parents paid for child care, and child care # is not among its noncash benefits, so a subsidy lowers this expense - # rather than adding to resources. This reads the SPM unit's expenses - # directly because childcare_expenses divides them among children and - # drops them from a unit with no child. + # rather than adding to resources. childcare_expenses holds that + # out-of-pocket amount, but it allocates expenses to children, so a + # unit with no child nets its own expenses here. + # Datasets fill the pre-subsidy input from the ASEC's reported spending, + # so on microdata this nets a modeled subsidy from a reported payment. subsidies = spm_unit("child_care_subsidies", period) - childcare_expenses = max_(pre_subsidy_expenses - subsidies, 0) + has_child = add(spm_unit, period, ["is_child"]) > 0 + out_of_pocket_expenses = where( + has_child, + spm_unit("childcare_expenses", period), + max_(pre_subsidy_expenses - subsidies, 0), + ) earned_cap = spm_unit("spm_unit_head_spouse_earned_cap", period) - return min_(work_expenses + childcare_expenses, earned_cap) + return min_(work_expenses + out_of_pocket_expenses, earned_cap) 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 d26c86ab5d9..d6beace2ce0 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,8 +9,6 @@ class spm_unit_benefits(Variable): unit = USD def formula(spm_unit, period, parameters): - # Child care subsidies stay out of this list: they lower the expense - # in spm_unit_capped_work_childcare_expenses instead. BENEFITS = [ "social_security", "ssi", @@ -101,4 +99,16 @@ def formula(spm_unit, period, parameters): BENEFITS.append("assigned_aca_ptc") if not parameters(period).gov.hud.abolition: BENEFITS.append("spm_unit_capped_housing_subsidy") + # From 2024, child care subsidies lower the expense in + # spm_unit_capped_work_childcare_expenses instead of adding to + # resources. That expense has no formula before 2024, so these three + # stay resources until then. + if period.start.year < 2024: + BENEFITS.extend( + [ + "co_ccap_subsidy", + "wa_child_care_subsidies", + "wv_child_care_subsidies", + ] + ) return add(spm_unit, period, BENEFITS) From 640753a617b8a052d1f1496645924d4a943ae1a7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 16:27:45 -0500 Subject: [PATCH 05/12] Keep pre-2024 child care subsidies in the reform copies The baseline keeps Colorado, Washington and West Virginia subsidies as resources before 2024, because the SPM expense has no formula then. The four reform copies of spm_unit_benefits dropped them in every year, so each override now restores what main carried: all three in the Texas rebate, Colorado in the Tlaib copies. Qualify the docstring's identity for a direct childcare_expenses input, and correct the integration YAML header. Co-Authored-By: Claude Opus 5.5 --- .../boost/boost_middle_class_tax_credit.py | 6 +++++ .../edaa_end_child_poverty_act.py | 6 +++++ .../congress/tlaib/end_child_poverty_act.py | 6 +++++ .../reforms/states/tx/rebate/tx_rebate.py | 12 +++++++++ .../spm_child_care_subsidies_integration.yaml | 7 +++--- .../boost/boost_middle_class_tax_credit.yaml | 23 +++++++++++++++++ .../edaa_end_child_poverty_act.yaml | 23 +++++++++++++++++ .../end_child_poverty_act/integration.yaml | 23 +++++++++++++++++ .../contrib/states/tx/rebate/tx_rebate.yaml | 25 +++++++++++++++++++ .../test_spm_child_care_subsidy_invariants.py | 3 +++ 10 files changed, 131 insertions(+), 3 deletions(-) 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 939e41d3330..55cbd3091d5 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 @@ -135,6 +135,12 @@ def formula(spm_unit, period, parameters): BENEFITS.append("aca_ptc") if not parameters(period).gov.hud.abolition: BENEFITS.append("spm_unit_capped_housing_subsidy") + # From 2024, child care subsidies lower the expense in + # spm_unit_capped_work_childcare_expenses instead of adding to + # resources. That expense has no formula before 2024, so this one + # stays a resource until then. + if period.start.year < 2024: + BENEFITS.append("co_ccap_subsidy") p = parameters( period ).gov.contrib.congress.tlaib.boost.middle_class_tax_credit 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 1dbc0b9ebb7..1c2a7e00bf3 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 @@ -151,6 +151,12 @@ def formula(spm_unit, period, parameters): BENEFITS.append("aca_ptc") if not parameters(period).gov.hud.abolition: BENEFITS.append("spm_unit_capped_housing_subsidy") + # From 2024, child care subsidies lower the expense in + # spm_unit_capped_work_childcare_expenses instead of adding to + # resources. That expense has no formula before 2024, so this one + # stays a resource until then. + if period.start.year < 2024: + BENEFITS.append("co_ccap_subsidy") return add(spm_unit, period, BENEFITS) 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 e8df22a6c4b..02bef27ec03 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -164,6 +164,12 @@ def formula(spm_unit, period, parameters): BENEFITS.append("aca_ptc") if not parameters(period).gov.hud.abolition: BENEFITS.append("spm_unit_capped_housing_subsidy") + # From 2024, child care subsidies lower the expense in + # spm_unit_capped_work_childcare_expenses instead of adding to + # resources. That expense has no formula before 2024, so this one + # stays a resource until then. + if period.start.year < 2024: + BENEFITS.append("co_ccap_subsidy") return add(spm_unit, period, BENEFITS) class income_tax_refundable_credits(Variable): diff --git a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py index b2b47519ac3..093653576fd 100644 --- a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py +++ b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py @@ -119,6 +119,18 @@ def formula(spm_unit, period, parameters): BENEFITS.append("assigned_aca_ptc") if not parameters(period).gov.hud.abolition: BENEFITS.append("spm_unit_capped_housing_subsidy") + # From 2024, child care subsidies lower the expense in + # spm_unit_capped_work_childcare_expenses instead of adding to + # resources. That expense has no formula before 2024, so these + # three stay resources until then. + if period.start.year < 2024: + BENEFITS.extend( + [ + "co_ccap_subsidy", + "wa_child_care_subsidies", + "wv_child_care_subsidies", + ] + ) return add(spm_unit, period, BENEFITS) class reform(Reform): diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml index 04e331f1380..baeed13dd31 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml @@ -2,9 +2,10 @@ # Alaska subsidies never reached the SPM before; Colorado, Washington and West # Virginia reached it as resources, on top of pre-subsidy expenses. # -# Each family is one working parent with earnings of $30,000 and a child aged 3 -# in care 8 hours a day, 5 days a week. Without weeks_worked, work expenses are -# zero, so the SPM expense is the out-of-pocket amount. +# Each family is one working parent and a child aged 3 in care 8 hours a day, 5 +# days a week. The 2026 cases earn $30,000 and the 2024 cases earn $24,000. +# Without weeks_worked, work expenses are zero, so the SPM expense is the +# out-of-pocket amount. - name: New York, Manhattan family with a subsidy just below the expense period: 2026 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml index b37e8c153b3..c87af91955b 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml @@ -211,3 +211,26 @@ output: household_benefits: 0 spm_unit_benefits: 0 + +- name: Before 2024 the Colorado subsidy stays a resource under this reform's list. + period: 2023 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + co_ccap_subsidy: 1 + boost_middle_class_tax_credit: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 1 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml index cf8c3331d02..56d00bdd3a1 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml @@ -263,3 +263,26 @@ output: household_benefits: 0 spm_unit_benefits: 0 + +- name: Before 2024 the Colorado subsidy stays a resource under this reform's list. + period: 2023 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + co_ccap_subsidy: 1 + ecpa_child_benefit: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 1 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml index 1d9d3f1e04e..4a475fdf252 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml @@ -159,3 +159,26 @@ output: household_benefits: 0 spm_unit_benefits: 0 + +- name: Before 2024 the Colorado subsidy stays a resource under this reform's list. + period: 2023 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + co_ccap_subsidy: 1 + ecpa_child_benefit: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 1 diff --git a/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml b/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml index b1e56bc9dde..a5b27076005 100644 --- a/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml +++ b/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml @@ -181,3 +181,28 @@ # not recur -- neither directly nor through the spm_unit_benefits path. tx_rebate: 0 spm_unit_benefits: 0 + +- name: Before 2024 these child care subsidies stay resources under this reform's list. + period: 2023 + reforms: policyengine_us.reforms.states.tx.rebate.tx_rebate.tx_rebate + input: + co_ccap_subsidy: 1 + wa_child_care_subsidies: 2 + wv_child_care_subsidies: 4 + tx_rebate: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 1 + 2 + 4 diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index b61870c1087..91a813139b6 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -14,6 +14,9 @@ expense(S) = min(W + max(E - S, 0), C) +A direct childcare_expenses input replaces max(E - S, 0) in a unit with a +child, so these properties cover units that leave that variable to its formula. + For every SPM unit: 1. The model's expense equals that reference, written from the documentation From 1a68fe7e47ef61b49e6ee7e706eb7b1a47ec9301 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 09:36:29 -0500 Subject: [PATCH 06/12] Pin the 2024 gate in each reform copy of the SPM benefits list The list test reads list literals only, so it could not see the pre-2024 clause in the four reform copies. A 2024 case in each reform's YAML file now fails if the clause loosens and a subsidy counts as a resource on top of the lower expense. Also qualify the docstring for person-level pre-subsidy inputs and for the Tlaib copies, which carry only Colorado's subsidy before 2024, and note in the integration header that one case sets no care hours. Co-Authored-By: Claude Opus 5.5 --- .../spm_child_care_subsidies_integration.yaml | 5 ++-- .../boost/boost_middle_class_tax_credit.yaml | 23 +++++++++++++++++ .../edaa_end_child_poverty_act.yaml | 23 +++++++++++++++++ .../end_child_poverty_act/integration.yaml | 23 +++++++++++++++++ .../contrib/states/tx/rebate/tx_rebate.yaml | 25 +++++++++++++++++++ .../test_spm_child_care_subsidy_invariants.py | 9 ++++--- 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml index baeed13dd31..0a569868a10 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml @@ -2,8 +2,9 @@ # Alaska subsidies never reached the SPM before; Colorado, Washington and West # Virginia reached it as resources, on top of pre-subsidy expenses. # -# Each family is one working parent and a child aged 3 in care 8 hours a day, 5 -# days a week. The 2026 cases earn $30,000 and the 2024 cases earn $24,000. +# Each family is one working parent and a child aged 3. Unless a case says +# otherwise, the child is in care 8 hours a day, 5 days a week. The 2026 cases +# earn $30,000 and the 2024 cases earn $24,000. # Without weeks_worked, work expenses are zero, so the SPM expense is the # out-of-pocket amount. diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml index c87af91955b..5758e3c9376 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/boost/boost_middle_class_tax_credit.yaml @@ -234,3 +234,26 @@ basic_income: 0 output: spm_unit_benefits: 1 + +- name: From 2024 the Colorado subsidy leaves this reform's list and lowers the SPM expense instead. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.boost.boost_middle_class_tax_credit.boost_middle_class_tax_credit + input: + co_ccap_subsidy: 1 + boost_middle_class_tax_credit: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml index 56d00bdd3a1..2fc3b39667b 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.yaml @@ -286,3 +286,26 @@ basic_income: 0 output: spm_unit_benefits: 1 + +- name: From 2024 the Colorado subsidy leaves this reform's list and lowers the SPM expense instead. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.economic_dignity_for_all_agenda.end_child_poverty_act + input: + co_ccap_subsidy: 1 + ecpa_child_benefit: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml b/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml index 4a475fdf252..239e984cd8c 100644 --- a/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml +++ b/policyengine_us/tests/policy/contrib/congress/tlaib/end_child_poverty_act/integration.yaml @@ -182,3 +182,26 @@ basic_income: 0 output: spm_unit_benefits: 1 + +- name: From 2024 the Colorado subsidy leaves this reform's list and lowers the SPM expense instead. + period: 2024 + reforms: policyengine_us.reforms.congress.tlaib.end_child_poverty_act.end_child_poverty_act + input: + co_ccap_subsidy: 1 + ecpa_child_benefit: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml b/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml index a5b27076005..f9a602be4ce 100644 --- a/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml +++ b/policyengine_us/tests/policy/contrib/states/tx/rebate/tx_rebate.yaml @@ -206,3 +206,28 @@ basic_income: 0 output: spm_unit_benefits: 1 + 2 + 4 + +- name: From 2024 these child care subsidies leave this reform's list and lower the SPM expense instead. + period: 2024 + reforms: policyengine_us.reforms.states.tx.rebate.tx_rebate.tx_rebate + input: + co_ccap_subsidy: 1 + wa_child_care_subsidies: 2 + wv_child_care_subsidies: 4 + tx_rebate: 0 + spm_unit_capped_housing_subsidy: 0 + social_security: 0 + ssi: 0 + ca_cvrp: 0 + snap: 0 + wic: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + tanf: 0 + high_efficiency_electric_home_rebate: 0 + residential_efficiency_electrification_rebate: 0 + unemployment_compensation: 0 + basic_income: 0 + output: + spm_unit_benefits: 0 diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index 91a813139b6..a69ae7aad75 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -14,8 +14,10 @@ expense(S) = min(W + max(E - S, 0), C) -A direct childcare_expenses input replaces max(E - S, 0) in a unit with a -child, so these properties cover units that leave that variable to its formula. +A unit with a child takes E from its members' pre_subsidy_childcare_expenses, +which an input can set apart from the unit's amount, and a direct +childcare_expenses input replaces max(E - S, 0) altogether. These properties +cover units that leave both to their formulas. For every SPM unit: @@ -33,7 +35,8 @@ and no program one of them adds, appears in spm_unit_benefits or in a reform's copy of that list, and all of them reach child_care_subsidies. Before 2024 the expense has no formula, so Colorado's, Washington's and West - Virginia's subsidies stay resources and still count once. + Virginia's subsidies stay resources and count once, as on main. The Tlaib + reform copies carry only Colorado's in those years, also as on main. 6. Differential. With real programs turned on and off, the expense falls by the amount the reference gives for the subsidy the program paid, and the SPM and household measures agree on what else changed: the change in SPM From 652e968a94bafb9e127604d73ccf45abe49a9100 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 18:42:31 -0500 Subject: [PATCH 07/12] Keep reported child care spending as the SPM expense over a dataset Datasets fill spm_unit_pre_subsidy_childcare_expenses from the ASEC's SPM_CHILDCAREXPNS, which already holds what parents paid after any subsidy, so netting a modeled subsidy from it counted the subsidy twice. Over a dataset the expense now keeps the reported amount under baseline policy, and a reform moves it by the change in subsidy: min(W + max(E - (S1 - S0), 0), C). Household calculations keep the input as a price and net the subsidy, as before. Add properties for both rules and a differential between them, and a microsimulation test over the shipped population. Co-Authored-By: Claude Opus 5.5 --- changelog.d/spm-child-care-subsidies.fixed.md | 2 +- .../test_spm_child_care_reported_expense.py | 115 ++++++++++ .../test_spm_child_care_subsidy_invariants.py | 214 +++++++++++++++++- ...spm_unit_capped_work_childcare_expenses.py | 36 ++- ...spm_unit_pre_subsidy_childcare_expenses.py | 5 + 5 files changed, 351 insertions(+), 21 deletions(-) create mode 100644 policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py diff --git a/changelog.d/spm-child-care-subsidies.fixed.md b/changelog.d/spm-child-care-subsidies.fixed.md index 090ad31ef85..95d3b483235 100644 --- a/changelog.d/spm-child-care-subsidies.fixed.md +++ b/changelog.d/spm-child-care-subsidies.fixed.md @@ -1 +1 @@ -Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. +Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense in household calculations, keep the ASEC's reported spending as that expense over a dataset while a reform moves it by the change in subsidy, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. diff --git a/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py b/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py new file mode 100644 index 00000000000..5d8d0ed5bbd --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py @@ -0,0 +1,115 @@ +"""Over a dataset, the SPM subtracts what parents reported paying for child care. + +Datasets fill spm_unit_pre_subsidy_childcare_expenses from the ASEC's +SPM_CHILDCAREXPNS, which holds what parents paid after any subsidy. So baseline +policy keeps that amount, and a reform moves it by the change in subsidy. These +tests run the rule over the shipped population rather than the situations in +test_spm_child_care_subsidy_invariants.py, so the dataset flag and the reform's +baseline arm come from loading a real file. +""" + +import numpy as np + +from policyengine_us.tests.microsimulation.populace_fixture import ( + default_population_sample, +) + +PERIOD = 2026 +TOLERANCE = 0.01 +FLAT_SUBSIDY = 1_000 + + +def spm_arrays(simulation): + return { + name: np.asarray(simulation.calculate(name, PERIOD), dtype=float) + for name in ( + "child_care_subsidies", + "spm_unit_capped_work_childcare_expenses", + "spm_unit_head_spouse_earned_cap", + "spm_unit_pre_subsidy_childcare_expenses", + "spm_unit_work_expenses", + ) + } + + +def test_baseline_expense_is_the_reported_amount_over_the_whole_population(): + from policyengine_us import Microsimulation + + simulation = Microsimulation() # populace_us_2024, full file + reported = simulation.calculate( + "spm_unit_pre_subsidy_childcare_expenses", PERIOD + ).values + work = simulation.calculate("spm_unit_work_expenses", PERIOD).values + cap = simulation.calculate("spm_unit_head_spouse_earned_cap", PERIOD).values + expense = simulation.calculate( + "spm_unit_capped_work_childcare_expenses", PERIOD + ).values + + # The identity would hold trivially if nobody reported paying. + assert (reported > 0).sum() > 1_000 + assert (expense == np.minimum(work + reported, cap)).all() + + +def test_reform_moves_the_reported_expense_by_the_change_in_subsidy(): + from policyengine_core.reforms import Reform + + from policyengine_us import Microsimulation + from policyengine_us.model_api import SPMUnit, USD, YEAR, Variable + + class child_care_subsidies(Variable): + value_type = float + entity = SPMUnit + definition_period = YEAR + label = "Child care subsidies" + unit = USD + + def formula(spm_unit, period, parameters): + return np.full(spm_unit.count, FLAT_SUBSIDY, dtype=float) + + class FlatSubsidy(Reform): + def apply(self): + self.update_variable(child_care_subsidies) + + dataset = default_population_sample() + baseline = spm_arrays(Microsimulation(dataset=dataset)) + simulation = Microsimulation(dataset=dataset, reform=FlatSubsidy) + reformed = spm_arrays(simulation) + arm = spm_arrays(simulation.baseline) + + # The reform's baseline arm matches a simulation with no reform. + for name, values in baseline.items(): + np.testing.assert_array_equal(arm[name], values, err_msg=name) + + reported = baseline["spm_unit_pre_subsidy_childcare_expenses"] + assert (reformed["child_care_subsidies"] == FLAT_SUBSIDY).all() + # The slice has to hold units the old rule got wrong: the model pays them a + # subsidy and they reported paying, so it netted one from the other. + paid = (baseline["child_care_subsidies"] > 0) & (reported > 0) + assert paid.any() + np.testing.assert_allclose( + baseline["spm_unit_capped_work_childcare_expenses"][paid], + np.minimum( + baseline["spm_unit_work_expenses"] + reported, + baseline["spm_unit_head_spouse_earned_cap"], + )[paid], + atol=TOLERANCE, + ) + # And units on each side of the floor. + assert ((reported > 0) & (reported < FLAT_SUBSIDY)).any() + assert (reported > FLAT_SUBSIDY).any() + + change = FLAT_SUBSIDY - baseline["child_care_subsidies"] + expected = np.minimum( + reformed["spm_unit_work_expenses"] + np.maximum(reported - change, 0), + reformed["spm_unit_head_spouse_earned_cap"], + ) + np.testing.assert_allclose( + reformed["spm_unit_capped_work_childcare_expenses"], + expected, + atol=TOLERANCE, + ) + # Some unit's expense has to move, or the comparison above proves nothing. + assert ( + reformed["spm_unit_capped_work_childcare_expenses"] + < baseline["spm_unit_capped_work_childcare_expenses"] + ).any() diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index a69ae7aad75..49f0e789921 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -52,10 +52,24 @@ can rise while SPM income falls. test_subsidy_at_the_cap_can_lower_spm_income pins one such case; the fall comes from those programs and not from this rule. -These properties read spm_unit_pre_subsidy_childcare_expenses as a pre-subsidy -price, which is what it means in a household calculation. Datasets fill it from -the ASEC's reported spending, so on microdata the rule nets a modeled subsidy -from a reported payment. +The properties above read spm_unit_pre_subsidy_childcare_expenses as a +pre-subsidy price, which is what it means in a household calculation. Datasets +fill it from the ASEC's reported spending, which is already what parents paid. +So over a dataset, with E the reported amount, S0 the subsidy baseline policy +pays and S1 the subsidy the simulated policy pays: + + expense(S1) = min(W + max(E - (S1 - S0), 0), C) + +7. Baseline policy over a dataset leaves the expense at min(W + E, C), the + amount Census subtracts, whatever subsidy the model pays. +8. Under any policy over a dataset, the expense equals that reference, with or + without a child in the unit. A policy that pays the same subsidy leaves the + expense where baseline policy put it. +9. Differential. Give a dataset the amount the household rule says a unit pays + at baseline policy. Then the two rules give the same expense under any + policy. With the same input and real programs, repealing a subsidy moves + SPM net income by the same amount under both rules, unless the earnings cap + binds under either. """ from functools import cache @@ -108,13 +122,15 @@ def build_units(units): "is_tax_unit_head": {YEAR: True}, "is_household_head": {YEAR: True}, } - people[child] = {"age": {YEAR: 4}, "is_tax_unit_dependent": {YEAR: True}} - members = [parent, child] + members = [parent] + marital_units[f"marital_unit_{i}"] = {"members": [parent]} + if unit.get("has_child", True): + people[child] = {"age": {YEAR: 4}, "is_tax_unit_dependent": {YEAR: True}} + members = [parent, child] + marital_units[f"child_marital_unit_{i}"] = {"members": [child]} state = unit["state"] tax_units[f"tax_unit_{i}"] = {"members": members} families[f"family_{i}"] = {"members": members} - marital_units[f"marital_unit_{i}"] = {"members": [parent]} - marital_units[f"child_marital_unit_{i}"] = {"members": [child]} spm_units[f"spm_unit_{i}"] = { "members": members, "spm_unit_pre_subsidy_childcare_expenses": {YEAR: unit["expenses"]}, @@ -379,3 +395,185 @@ def test_subsidy_at_the_cap_can_lower_spm_income(): change("spm_unit_benefits") - change("spm_unit_taxes"), abs=INCOME_TOLERANCE ) assert change("spm_unit_net_income") < 0 + + +def with_baseline_arm(simulation): + """A branch named baseline, wired as a reform simulation wires its own. These + properties set each arm's subsidy directly, and a real reform builds a whole + tax-benefit system; the repeal test below and the microsimulation tests run + that path.""" + simulation.baseline = simulation.get_branch("baseline") + return simulation + + +def over_dataset(situation): + """A simulation that reads its inputs as a dataset's. Core sets this flag + when it loads a file; a situation lets these properties choose the inputs.""" + simulation = Simulation(situation=situation) + simulation.is_over_dataset = True + return simulation + + +def set_subsidies(simulation, baseline_subsidies, policy_subsidies): + simulation.baseline.set_input("child_care_subsidies", YEAR, baseline_subsidies) + simulation.set_input("child_care_subsidies", YEAR, policy_subsidies) + + +def capped_expense(simulation): + return np.asarray( + simulation.calculate("spm_unit_capped_work_childcare_expenses", YEAR), + dtype=float, + ) + + +dataset_units = st.lists( + st.fixed_dictionaries( + { + "state": st.sampled_from(STATES), + "expenses": amounts, + "subsidy": amounts, + "policy_subsidy": amounts, + "work": st.integers(0, 6_000), + "cap": st.integers(0, 60_000), + "has_child": st.booleans(), + } + ), + min_size=1, + max_size=4, +) + + +@settings(max_examples=25, deadline=None) +@given(dataset_units) +def test_baseline_policy_over_a_dataset_keeps_the_reported_amount(units): + plain = over_dataset(build_units(units)) + simulation = with_baseline_arm(over_dataset(build_units(units))) + # 7. The subsidy input differs by unit and the expense ignores it. A + # simulation with no baseline and a baseline arm both hold baseline policy. + for baseline in (plain, simulation.baseline): + for unit, expense in zip(units, capped_expense(baseline)): + assert expense == pytest.approx( + min(unit["work"] + unit["expenses"], unit["cap"]), abs=TOLERANCE + ) + # 8. A policy that pays the same subsidy leaves the expense identical, with + # no rounding error from netting. + assert (capped_expense(simulation) == capped_expense(simulation.baseline)).all() + + +@settings(max_examples=25, deadline=None) +@given(dataset_units) +@example( + # A policy that pays more than the unit reported paying, so the floor binds. + [ + { + "state": "NY", + "expenses": 1_000, + "subsidy": 0, + "policy_subsidy": 5_000, + "work": 100, + "cap": 60_000, + "has_child": True, + } + ] +) +@example( + # A repeal that takes a unit with no child to the earnings cap. + [ + { + "state": "TX", + "expenses": 5_000, + "subsidy": 8_000, + "policy_subsidy": 0, + "work": 100, + "cap": 6_000, + "has_child": False, + } + ] +) +def test_policy_over_a_dataset_moves_the_expense_by_the_subsidy_change(units): + # 8. + simulation = with_baseline_arm(over_dataset(build_units(units))) + set_subsidies( + simulation, + [unit["subsidy"] for unit in units], + [unit["policy_subsidy"] for unit in units], + ) + for unit, expense in zip(units, capped_expense(simulation)): + assert expense == pytest.approx( + reference_expense( + unit["expenses"], + unit["policy_subsidy"] - unit["subsidy"], + unit["work"], + unit["cap"], + ), + abs=TOLERANCE, + ) + + +@settings(max_examples=25, deadline=None) +@given(dataset_units) +def test_dataset_and_household_rules_agree_on_the_expense(units): + # 9. A subsidy no larger than the price keeps the floor out of the way at + # baseline policy, so the reported amount is the price less the subsidy. + baseline_subsidies = [min(u["subsidy"], u["expenses"]) for u in units] + policy_subsidies = [unit["policy_subsidy"] for unit in units] + reported = [ + {**unit, "expenses": unit["expenses"] - subsidy} + for unit, subsidy in zip(units, baseline_subsidies) + ] + household = with_baseline_arm(Simulation(situation=build_units(units))) + dataset = with_baseline_arm(over_dataset(build_units(reported))) + set_subsidies(household, baseline_subsidies, policy_subsidies) + set_subsidies(dataset, baseline_subsidies, policy_subsidies) + assert capped_expense(dataset) == pytest.approx( + capped_expense(household), abs=TOLERANCE + ) + assert capped_expense(dataset.baseline) == pytest.approx( + capped_expense(household.baseline), abs=TOLERANCE + ) + + +@pytest.mark.parametrize("state, county_fips", [("NY", "36061"), ("TX", None)]) +def test_repeal_moves_dataset_and_household_income_by_the_same_amount( + state, county_fips +): + # 9. Real programs and a real reform. The dataset reads $14,000 as what + # parents paid and the household calculation as the price. Neither rule's + # cap binds and both expenses rise by the subsidy, so SPM income falls by + # the same amount. + situation = family(state, 30_000, 14_000) + parent = situation["people"]["parent"] + del parent["weeks_worked"] + # The columns datasets hold. Simulation moves employment_income to its + # pre-response variable after core builds the baseline arm, so the arm would + # read no earnings wherever a formula reads that variable. + parent["employment_income_before_lsr"] = parent.pop("employment_income") + parent["weekly_hours_worked_before_lsr"] = parent.pop("weekly_hours_worked") + if county_fips: + situation["households"]["household"]["county_fips"] = {YEAR: county_fips} + _, household_change = with_and_without_subsidies(situation) + dataset = Simulation(situation=situation, reform=NoSubsidies) + dataset.is_over_dataset = True + dataset.baseline.is_over_dataset = True + + def dataset_change(variable): + return float(dataset.baseline.calculate(variable, YEAR).sum()) - float( + dataset.calculate(variable, YEAR).sum() + ) + + subsidy = float(dataset.baseline.calculate("child_care_subsidies", YEAR).sum()) + assert subsidy > 0 + assert float(dataset.calculate("child_care_subsidies", YEAR).sum()) == 0 + assert capped_expense(dataset.baseline).sum() == pytest.approx( + 14_000, abs=TOLERANCE + ) + assert dataset_change("spm_unit_capped_work_childcare_expenses") == pytest.approx( + -subsidy, abs=TOLERANCE + ) + assert household_change("spm_unit_capped_work_childcare_expenses") == pytest.approx( + -subsidy, abs=TOLERANCE + ) + assert household_change("spm_unit_net_income") != 0 + assert dataset_change("spm_unit_net_income") == pytest.approx( + household_change("spm_unit_net_income"), abs=INCOME_TOLERANCE + ) diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py index 8776b697086..4fe149f1dc3 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py @@ -16,17 +16,29 @@ def formula_2024(spm_unit, period, parameters): ) # The SPM subtracts what parents paid for child care, and child care # is not among its noncash benefits, so a subsidy lowers this expense - # rather than adding to resources. childcare_expenses holds that - # out-of-pocket amount, but it allocates expenses to children, so a - # unit with no child nets its own expenses here. - # Datasets fill the pre-subsidy input from the ASEC's reported spending, - # so on microdata this nets a modeled subsidy from a reported payment. - subsidies = spm_unit("child_care_subsidies", period) - has_child = add(spm_unit, period, ["is_child"]) > 0 - out_of_pocket_expenses = where( - has_child, - spm_unit("childcare_expenses", period), - max_(pre_subsidy_expenses - subsidies, 0), - ) + # rather than adding to resources. + simulation = spm_unit.simulation + if simulation.is_over_dataset: + # Datasets fill the pre-subsidy input from the ASEC's reported + # spending, which is already what parents paid. Baseline policy + # keeps that amount, and a reform moves it by the change in subsidy. + out_of_pocket_expenses = pre_subsidy_expenses + if simulation.baseline is not None and simulation.branch_name != "baseline": + baseline_spm_unit = simulation.baseline.populations["spm_unit"] + subsidy_change = spm_unit( + "child_care_subsidies", period + ) - baseline_spm_unit("child_care_subsidies", period) + out_of_pocket_expenses = max_(pre_subsidy_expenses - subsidy_change, 0) + else: + # childcare_expenses holds the out-of-pocket amount, but it + # allocates expenses to children, so a unit with no child nets its + # own expenses here. + subsidies = spm_unit("child_care_subsidies", period) + has_child = add(spm_unit, period, ["is_child"]) > 0 + out_of_pocket_expenses = where( + has_child, + spm_unit("childcare_expenses", period), + max_(pre_subsidy_expenses - subsidies, 0), + ) earned_cap = spm_unit("spm_unit_head_spouse_earned_cap", period) return min_(work_expenses + out_of_pocket_expenses, earned_cap) diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py index 6d413264037..d68a9287ca0 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py @@ -5,6 +5,11 @@ class spm_unit_pre_subsidy_childcare_expenses(Variable): value_type = float entity = SPMUnit label = "SPM unit pre subsidy child care expenses" + documentation = ( + "In a household calculation, the price of care before any subsidy. " + "Datasets fill it from the ASEC's SPM_CHILDCAREXPNS, which holds what " + "parents reported paying after any subsidy." + ) definition_period = YEAR uprating = "gov.bls.cpi.cpi_u" unit = USD From 08367acb317b4ba88b237f83daafed8c6dc2612a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 23:57:55 -0500 Subject: [PATCH 08/12] Pin the baseline arm the dataset rule reads, and set W and C on the policy side Main's #9698 now branches a reform's baseline arm again after the constructors move the pre-response inputs. Over a dataset, the SPM expense reads its baseline subsidy from that arm, so pin it there: - Add tests/core/test_reform_baseline_arm_inputs.py. Under a reform that changes nothing, the reform and its arm match a plain run on 102 households of raw columns, two families in every state, and on a situation that names New York only through state_code_str. With _rebuild_baseline_branch disabled, both cases fail. - Keep the parent's raw earnings and hours in the repeal test, which then fails in New York without the rebuild. - Set work expenses and the earnings cap on the policy side only in property 8, with an example that fails if the rule reads either from the arm. - Drop the dead branch_name guard, document the entry points that build no arm, and word the reported-spending reading as an inference from the ASEC question. - Update _baseline_measurement_branch's docstring, which still said the arm keeps the raw inputs. Co-Authored-By: Claude Opus 5.5 --- .../core/test_reform_baseline_arm_inputs.py | 191 ++++++++++++++++++ .../test_spm_child_care_reported_expense.py | 5 +- .../test_spm_child_care_subsidy_invariants.py | 57 ++++-- .../behavioral_response_measurements.py | 6 +- ...spm_unit_capped_work_childcare_expenses.py | 18 +- 5 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 policyengine_us/tests/core/test_reform_baseline_arm_inputs.py diff --git a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py new file mode 100644 index 00000000000..17b9ef0e330 --- /dev/null +++ b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py @@ -0,0 +1,191 @@ +"""A reform's baseline arm reads the inputs a simulation with no reform reads. + +Core builds the arm inside its ``__init__``. The country simulations then move +``employment_income``, ``self_employment_income``, ``weekly_hours_worked`` and +``long_term_capital_gains`` to their pre-response variables and fill +``state_code`` from ``state_code_str``, and a branch keeps none of what its +parent stores later. So the arm read 0 from the pre-response variables: under +any reform it saw no hours worked and paid no New York child care subsidy to a +family a simulation with no reform paid one. Over a dataset the SPM expense +moves by the change in subsidy it reads from that arm, so the gap became a +double count. ``system._rebuild_baseline_arm`` rebuilds the arm from the final +inputs. + +Invariant: under a reform that changes nothing, the reform and its baseline +arm compute what a simulation with no reform computes, for every household, +whether a situation or a dataset of raw columns supplies the inputs. +""" + +from itertools import product + +import numpy as np +import pandas as pd +from policyengine_core.data import Dataset +from policyengine_core.reforms import Reform + +from policyengine_us import Microsimulation, Simulation + +YEAR = 2026 +TOLERANCE = 0.01 +# Every state and DC, as state_name reads them. +STATE_FIPS = ( + *(1, 2, 4, 5, 6, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23), + *(24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41), + *(42, 44, 45, 46, 47, 48, 49, 50, 51, 53, 54, 55, 56), +) +NEW_YORK = 36 +# A parent and a child aged 3 in full-time care. Each tuple holds the parent's +# wages, self-employment income, long-term capital gains, weekly hours and +# Social Security retirement benefit, then what the unit paid for care. +FAMILIES = ( + (30_000, 0, 0, 40, 0, 14_000), + (15_000, 3_000, 1_000, 20, 1_200, 6_000), +) +PRE_RESPONSE = ( + "employment_income_before_lsr", + "self_employment_income_before_lsr", + "weekly_hours_worked_before_lsr", + "long_term_capital_gains_before_response", +) +OUTPUTS = ( + "child_care_subsidies", + "spm_unit_capped_work_childcare_expenses", + "social_security", + "snap", + "income_tax", + "spm_unit_net_income", + "household_net_income", +) + + +class NoChange(Reform): + def apply(self): + pass + + +def dataset(): + columns = {} + + def add(name, parent, child): + columns.setdefault(name, []).extend([parent, child]) + + for unit, (fips, family) in enumerate(product(STATE_FIPS, FAMILIES)): + wages, self_employment, gains, hours, retirement, expenses = family + add("person_id", 2 * unit, 2 * unit + 1) + for entity in ("household", "tax_unit", "spm_unit", "family"): + add(f"{entity}_id", unit, unit) + add("marital_unit_id", 2 * unit, 2 * unit + 1) + add(f"age__{YEAR}", 30, 3) + add(f"employment_income__{YEAR}", wages, 0) + add(f"self_employment_income__{YEAR}", self_employment, 0) + add(f"long_term_capital_gains__{YEAR}", gains, 0) + add(f"weekly_hours_worked__{YEAR}", hours, 0) + add(f"social_security_retirement__{YEAR}", retirement, 0) + add(f"is_tax_unit_head__{YEAR}", True, False) + add(f"is_household_head__{YEAR}", True, False) + add(f"is_tax_unit_dependent__{YEAR}", False, True) + add(f"childcare_hours_per_day__{YEAR}", 0, 8) + add(f"childcare_days_per_week__{YEAR}", 0, 5) + add(f"childcare_attending_days_per_month__{YEAR}", 0, 21) + add(f"spm_unit_pre_subsidy_childcare_expenses__{YEAR}", expenses, expenses) + add(f"state_fips__{YEAR}", fips, fips) + add(f"household_weight__{YEAR}", 1, 1) + return Dataset.from_dataframe(pd.DataFrame(columns), time_period=YEAR) + + +def situation(): + """The first family in New York, naming its state only through + state_code_str, with the second family's other income.""" + members = ["parent", "child"] + return { + "people": { + "parent": { + "age": {YEAR: 30}, + "employment_income": {YEAR: 30_000}, + "self_employment_income": {YEAR: 3_000}, + "long_term_capital_gains": {YEAR: 1_000}, + "weekly_hours_worked": {YEAR: 40}, + "social_security_retirement": {YEAR: 1_200}, + "is_tax_unit_head": {YEAR: True}, + "is_household_head": {YEAR: True}, + }, + "child": { + "age": {YEAR: 3}, + "childcare_hours_per_day": {YEAR: 8}, + "childcare_days_per_week": {YEAR: 5}, + "childcare_attending_days_per_month": {YEAR: 21}, + "is_tax_unit_dependent": {YEAR: True}, + }, + }, + "tax_units": {"tax_unit": {"members": members}}, + "families": {"family": {"members": members}}, + "marital_units": { + "parent_marital_unit": {"members": ["parent"]}, + "child_marital_unit": {"members": ["child"]}, + }, + "spm_units": { + "spm_unit": { + "members": members, + "spm_unit_pre_subsidy_childcare_expenses": {YEAR: 14_000}, + } + }, + "households": { + "household": {"members": members, "state_code_str": {YEAR: "NY"}} + }, + } + + +def values(simulation): + return { + variable: np.asarray(simulation.calculate(variable, YEAR), dtype=float) + for variable in PRE_RESPONSE + OUTPUTS + } + + +def assert_both_arms_match(plain, reformed): + arm = reformed.baseline + assert reformed.branches["baseline"] is arm + assert arm.parent_branch is reformed + assert arm.baseline is None + # The arm owns its policy, which names the arm rather than the one it + # replaced. + assert arm.tax_benefit_system is not reformed.tax_benefit_system + assert arm.tax_benefit_system.simulation is arm + # No calculation has run yet, so the arm holds only inputs, and no value + # the reform's policy produced. + assert arm.drop_computed_arrays() == 0 + expected = values(plain) + for name, simulation in (("reform", reformed), ("baseline arm", arm)): + for variable, array in values(simulation).items(): + np.testing.assert_allclose( + array, + expected[variable], + atol=TOLERANCE, + err_msg=f"{name}: {variable}", + ) + return expected + + +def test_dataset_arms_match_a_simulation_with_no_reform(): + plain = Microsimulation(dataset=dataset()) + expected = assert_both_arms_match( + plain, Microsimulation(dataset=dataset(), reform=NoChange) + ) + states = plain.calculate("state_code", YEAR).values + assert len(set(states)) == len(STATE_FIPS) + # The comparison covers households these programs pay, New York's first + # family among them, which the arm used to pay nothing. + subsidies = expected["child_care_subsidies"] + assert (subsidies > 0).sum() >= 10 + assert subsidies[STATE_FIPS.index(NEW_YORK) * len(FAMILIES)] > 0 + assert (expected["social_security"] > 0).any() + + +def test_situation_arms_match_a_simulation_with_no_reform(): + plain = Simulation(situation=situation()) + reformed = Simulation(situation=situation(), reform=NoChange) + expected = assert_both_arms_match(plain, reformed) + assert expected["child_care_subsidies"].sum() > 0 + for simulation in (plain, reformed, reformed.baseline): + state = simulation.calculate("state_code", YEAR).decode_to_str() + assert state.tolist() == ["NY"] diff --git a/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py b/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py index 5d8d0ed5bbd..328c171fc6b 100644 --- a/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py +++ b/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py @@ -1,8 +1,9 @@ """Over a dataset, the SPM subtracts what parents reported paying for child care. Datasets fill spm_unit_pre_subsidy_childcare_expenses from the ASEC's -SPM_CHILDCAREXPNS, which holds what parents paid after any subsidy. So baseline -policy keeps that amount, and a reform moves it by the change in subsidy. These +SPM_CHILDCAREXPNS, what parents reported spending on child care, which the +rule reads as what they paid after any subsidy. So baseline policy keeps that +amount, and a reform moves it by the change in subsidy. These tests run the rule over the shipped population rather than the situations in test_spm_child_care_subsidy_invariants.py, so the dataset flag and the reform's baseline arm come from loading a real file. diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index 49f0e789921..b10bc5f7c0a 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -54,7 +54,8 @@ The properties above read spm_unit_pre_subsidy_childcare_expenses as a pre-subsidy price, which is what it means in a household calculation. Datasets -fill it from the ASEC's reported spending, which is already what parents paid. +fill it from the ASEC, which asks parents how much they spent on child care, +and these properties read that amount as what parents paid after any subsidy. So over a dataset, with E the reported amount, S0 the subsidy baseline policy pays and S1 the subsidy the simulated policy pays: @@ -63,7 +64,8 @@ 7. Baseline policy over a dataset leaves the expense at min(W + E, C), the amount Census subtracts, whatever subsidy the model pays. 8. Under any policy over a dataset, the expense equals that reference, with or - without a child in the unit. A policy that pays the same subsidy leaves the + without a child in the unit, and W and C come from the policy simulated. A + policy that pays the same subsidy and leaves W and C alone leaves the expense where baseline policy put it. 9. Differential. Give a dataset the amount the household rule says a unit pays at baseline policy. Then the two rules give the same expense under any @@ -435,6 +437,8 @@ def capped_expense(simulation): "policy_subsidy": amounts, "work": st.integers(0, 6_000), "cap": st.integers(0, 60_000), + "policy_work": st.integers(0, 6_000), + "policy_cap": st.integers(0, 60_000), "has_child": st.booleans(), } ), @@ -472,6 +476,8 @@ def test_baseline_policy_over_a_dataset_keeps_the_reported_amount(units): "policy_subsidy": 5_000, "work": 100, "cap": 60_000, + "policy_work": 100, + "policy_cap": 60_000, "has_child": True, } ] @@ -486,25 +492,54 @@ def test_baseline_policy_over_a_dataset_keeps_the_reported_amount(units): "policy_subsidy": 0, "work": 100, "cap": 6_000, + "policy_work": 100, + "policy_cap": 6_000, "has_child": False, } ] ) +@example( + # A policy that pays the same subsidy but moves work expenses and the cap: + # 3,500 reads both from the policy, 3,100 would read W and 3,900 would read + # C from baseline policy. + [ + { + "state": "NY", + "expenses": 3_000, + "subsidy": 500, + "policy_subsidy": 500, + "work": 100, + "cap": 60_000, + "policy_work": 900, + "policy_cap": 3_500, + "has_child": True, + } + ] +) def test_policy_over_a_dataset_moves_the_expense_by_the_subsidy_change(units): - # 8. + # 8. Work expenses and the cap change on the policy's side only, which + # stores them after the baseline arm branches off. simulation = with_baseline_arm(over_dataset(build_units(units))) set_subsidies( simulation, [unit["subsidy"] for unit in units], [unit["policy_subsidy"] for unit in units], ) + simulation.set_input( + "spm_unit_work_expenses", YEAR, [unit["policy_work"] for unit in units] + ) + simulation.set_input( + "spm_unit_head_spouse_earned_cap", + YEAR, + [unit["policy_cap"] for unit in units], + ) for unit, expense in zip(units, capped_expense(simulation)): assert expense == pytest.approx( reference_expense( unit["expenses"], unit["policy_subsidy"] - unit["subsidy"], - unit["work"], - unit["cap"], + unit["policy_work"], + unit["policy_cap"], ), abs=TOLERANCE, ) @@ -540,15 +575,11 @@ def test_repeal_moves_dataset_and_household_income_by_the_same_amount( # 9. Real programs and a real reform. The dataset reads $14,000 as what # parents paid and the household calculation as the price. Neither rule's # cap binds and both expenses rise by the subsidy, so SPM income falls by - # the same amount. + # the same amount. The parent's earnings and hours stay raw inputs, which + # the simulation moves to their pre-response variables: the baseline arm + # has to read the moved values, or New York's arm sees no hours worked. situation = family(state, 30_000, 14_000) - parent = situation["people"]["parent"] - del parent["weeks_worked"] - # The columns datasets hold. Simulation moves employment_income to its - # pre-response variable after core builds the baseline arm, so the arm would - # read no earnings wherever a formula reads that variable. - parent["employment_income_before_lsr"] = parent.pop("employment_income") - parent["weekly_hours_worked_before_lsr"] = parent.pop("weekly_hours_worked") + del situation["people"]["parent"]["weeks_worked"] if county_fips: situation["households"]["household"]["county_fips"] = {YEAR: county_fips} _, household_change = with_and_without_subsidies(situation) diff --git a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index 780033b204e..328d88cf0a3 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -69,10 +69,8 @@ def _baseline_measurement_branch(simulation): - A branch shares its parent's baseline, and on policyengine-core before #587 so does a clone, so their baseline holds the parent's inputs. - - A reform simulation's baseline is a branch taken while it was built, - before this package moved ``employment_income`` and the other - pre-response inputs to their ``_before_lsr`` variables, so a household - baseline keeps them as overrides; later inputs never reach it. + - A reform simulation branches its baseline at the end of construction, + so inputs set on the simulation afterwards never reach it. ``simulation.get_branch("baseline")``, used before, was worse: for a branch or an early clone it made a new branch under the *reform* policy, so the diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py index 4fe149f1dc3..0d98c64d0b3 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py @@ -7,6 +7,14 @@ class spm_unit_capped_work_childcare_expenses(Variable): label = "SPM unit work and childcare expenses" definition_period = YEAR unit = USD + documentation = ( + "Over a dataset, the child care part keeps the reported amount under " + "baseline policy and moves by the change in subsidy under a reform, " + "which it reads from the reform's baseline arm. Core builds that arm " + "only for a reform passed as reform=. A reformed system passed as " + "tax_benefit_system=, or a reform applied with apply_reform after " + "construction, gets no arm, so the expense keeps the reported amount." + ) reference = "https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf#page=17" def formula_2024(spm_unit, period, parameters): @@ -19,11 +27,13 @@ def formula_2024(spm_unit, period, parameters): # rather than adding to resources. simulation = spm_unit.simulation if simulation.is_over_dataset: - # Datasets fill the pre-subsidy input from the ASEC's reported - # spending, which is already what parents paid. Baseline policy - # keeps that amount, and a reform moves it by the change in subsidy. + # Datasets fill the pre-subsidy input from the ASEC, which asks + # parents how much they spent on child care, and this rule reads + # that as what they paid after any subsidy. Baseline policy keeps + # that amount, and a reform moves it by the change in subsidy. A + # baseline arm has no baseline of its own, so it keeps the amount. out_of_pocket_expenses = pre_subsidy_expenses - if simulation.baseline is not None and simulation.branch_name != "baseline": + if simulation.baseline is not None: baseline_spm_unit = simulation.baseline.populations["spm_unit"] subsidy_change = spm_unit( "child_care_subsidies", period From 6946cd74a45d5dee91c657781ce22a111c61066b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 00:00:50 -0500 Subject: [PATCH 09/12] Name main's arm rebuild in the arm test's docstring The docstring still named _rebuild_baseline_arm, which this branch dropped when it merged main. Main's PolicyEngine/policyengine-us#9698 calls it _rebuild_baseline_branch. Name the moved inputs through PRE_RESPONSE_INPUTS, which also lists sstb_self_employment_income. Co-Authored-By: Claude Opus 5.5 --- .../core/test_reform_baseline_arm_inputs.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py index 17b9ef0e330..d84e46e86d6 100644 --- a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py +++ b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py @@ -1,15 +1,16 @@ """A reform's baseline arm reads the inputs a simulation with no reform reads. Core builds the arm inside its ``__init__``. The country simulations then move -``employment_income``, ``self_employment_income``, ``weekly_hours_worked`` and -``long_term_capital_gains`` to their pre-response variables and fill +the aggregates in ``PRE_RESPONSE_INPUTS``, such as ``employment_income`` and +``weekly_hours_worked``, to their pre-response variables and fill ``state_code`` from ``state_code_str``, and a branch keeps none of what its -parent stores later. So the arm read 0 from the pre-response variables: under -any reform it saw no hours worked and paid no New York child care subsidy to a -family a simulation with no reform paid one. Over a dataset the SPM expense -moves by the change in subsidy it reads from that arm, so the gap became a -double count. ``system._rebuild_baseline_arm`` rebuilds the arm from the final -inputs. +parent stores later. Before PolicyEngine/policyengine-us#9698 the arm therefore +read 0 from the pre-response variables: under any reform it saw no hours +worked and paid no New York child care subsidy to a family that a simulation +with no reform paid one. Over a dataset the SPM expense moves by the change in +subsidy it reads from that arm, so the gap became a double count. +``system._rebuild_baseline_branch`` now branches the arm again after the +moves. Invariant: under a reform that changes nothing, the reform and its baseline arm compute what a simulation with no reform computes, for every household, From 678d423e66f1212afa413ba50d385ee4f6e4bf6e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 04:29:02 -0500 Subject: [PATCH 10/12] Drop holders built for another policy when rebuilding the baseline arm _rebuild_baseline_branch cloned the reform simulation and rebound the clone's holders to baseline variables, but it kept each holder the reform had built in another entity or with other ETERNITY storage. Construction, subsample and _baseline_measurement_branch all drop those. Under a reform that stores child_care_subsidies for all time, a New York family with a $500 input got S0 = $500 from the arm, where baseline policy pays it $13,916.40, so its reform expense came out $14,000.00 where the dataset rule gives $27,416.40. - Call _drop_holders_built_for_another_policy before _rebind_holders. - Add test_the_arm_calculates_what_the_reform_stores_for_all_time, and check in every arm test that each holder suits its arm's variable. - Check holder binding before subsampling as well. Under the redefining reform, the arm copied an empty yearly holder for the baseline's ETERNITY is_household_head. - Document that apply_reform builds no arm while a simulation built with reform= keeps its arm, and word the input's after-subsidy reading as the model's. Co-Authored-By: Claude Opus 5.5 --- changelog.d/spm-child-care-subsidies.fixed.md | 2 +- policyengine_us/system.py | 12 ++- .../core/test_reform_baseline_arm_inputs.py | 88 +++++++++++++++++-- policyengine_us/tests/core/test_spm_system.py | 9 +- ...spm_unit_capped_work_childcare_expenses.py | 8 +- ...spm_unit_pre_subsidy_childcare_expenses.py | 6 +- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/changelog.d/spm-child-care-subsidies.fixed.md b/changelog.d/spm-child-care-subsidies.fixed.md index 95d3b483235..5f55e8d652c 100644 --- a/changelog.d/spm-child-care-subsidies.fixed.md +++ b/changelog.d/spm-child-care-subsidies.fixed.md @@ -1 +1 @@ -Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense in household calculations, keep the ASEC's reported spending as that expense over a dataset while a reform moves it by the change in subsidy, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. +Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense in household calculations, keep the ASEC's reported spending as that expense over a dataset while a reform moves it by the change in subsidy, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. Rebuild a reform's baseline arm without the holders the reform built for its own definition of a variable in another entity or with other ETERNITY storage, as core's own branch does. diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 65a99683f52..8ae78e287c6 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -265,7 +265,10 @@ def _rebuild_baseline_branch(simulation): ``input_variables``. Its TANF counted no earnings, and the baseline marginal tax rate that labor supply responses compare against erased pre-response wages. Branching again from the simulation, as core's - ``subsample`` does, gives the baseline the same inputs as the reform. + ``subsample`` does, gives the baseline the same inputs as the reform, + except those the reform stored for its own definition of a variable in + another entity or with other ``ETERNITY`` storage, which core's branch + dropped too. """ baseline = simulation.branches.pop("baseline", None) if baseline is None: @@ -278,7 +281,12 @@ def _rebuild_baseline_branch(simulation): rebuilt.tracer = simulation.tracer rebuilt.tax_benefit_system = baseline.tax_benefit_system # Cloning bound holders to reform variables. Restoring policy must also - # restore those bindings and remove inputs that exist only in the reform. + # drop holders whose entity or storage the baseline's variables cannot + # use, restore the other bindings and remove inputs that exist only in + # the reform. A kept holder would hand the baseline the reform's input: + # under a reform that stores ``child_care_subsidies`` for all time, the + # baseline read that input in place of its own formula. + rebuilt._drop_holders_built_for_another_policy() rebuilt._rebind_holders() rebuilt._isolate_parameter_tracing() simulation.baseline = rebuilt diff --git a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py index d84e46e86d6..1406a05ea0e 100644 --- a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py +++ b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py @@ -10,11 +10,14 @@ with no reform paid one. Over a dataset the SPM expense moves by the change in subsidy it reads from that arm, so the gap became a double count. ``system._rebuild_baseline_branch`` now branches the arm again after the -moves. +moves. Like core's branch, it drops each holder the reform built in another +entity or with other ``ETERNITY`` storage than the baseline's variable has. +A kept holder hands the arm the reform's input in place of its formula. -Invariant: under a reform that changes nothing, the reform and its baseline +Invariants: under a reform that changes nothing, the reform and its baseline arm compute what a simulation with no reform computes, for every household, -whether a situation or a dataset of raw columns supplies the inputs. +whether a situation or a dataset of raw columns supplies the inputs. Under any +reform, each holder in either arm suits that arm's definition of its variable. """ from itertools import product @@ -22,9 +25,12 @@ import numpy as np import pandas as pd from policyengine_core.data import Dataset +from policyengine_core.periods import ETERNITY from policyengine_core.reforms import Reform +from policyengine_core.variables import Variable from policyengine_us import Microsimulation, Simulation +from policyengine_us.entities import SPMUnit YEAR = 2026 TOLERANCE = 0.01 @@ -64,13 +70,26 @@ def apply(self): pass -def dataset(): +class child_care_subsidies(Variable): + # Stored for all time, where baseline policy calculates it each year. + value_type = float + entity = SPMUnit + label = "Child care subsidies" + definition_period = ETERNITY + + +class StoreSubsidiesForAllTime(Reform): + def apply(self): + self.update_variable(child_care_subsidies) + + +def dataset(states=STATE_FIPS, families=FAMILIES, eternal_subsidy=None): columns = {} def add(name, parent, child): columns.setdefault(name, []).extend([parent, child]) - for unit, (fips, family) in enumerate(product(STATE_FIPS, FAMILIES)): + for unit, (fips, family) in enumerate(product(states, families)): wages, self_employment, gains, hours, retirement, expenses = family add("person_id", 2 * unit, 2 * unit + 1) for entity in ("household", "tax_unit", "spm_unit", "family"): @@ -91,6 +110,8 @@ def add(name, parent, child): add(f"spm_unit_pre_subsidy_childcare_expenses__{YEAR}", expenses, expenses) add(f"state_fips__{YEAR}", fips, fips) add(f"household_weight__{YEAR}", 1, 1) + if eternal_subsidy is not None: + add("child_care_subsidies__eternity", eternal_subsidy, eternal_subsidy) return Dataset.from_dataframe(pd.DataFrame(columns), time_period=YEAR) @@ -143,6 +164,20 @@ def values(simulation): } +def assert_holders_suit_their_variables(simulation): + """Each holder sits in its variable's entity, with ``ETERNITY`` storage + exactly when the simulation's own definition of the variable has it.""" + variables = simulation.tax_benefit_system.variables + for population in simulation.populations.values(): + for name, holder in population._holders.items(): + variable = variables.get(name) + assert holder.variable is variable, name + assert variable.entity.key == population.entity.key, name + assert holder._memory_storage.is_eternal == ( + variable.definition_period == ETERNITY + ), name + + def assert_both_arms_match(plain, reformed): arm = reformed.baseline assert reformed.branches["baseline"] is arm @@ -155,6 +190,8 @@ def assert_both_arms_match(plain, reformed): # No calculation has run yet, so the arm holds only inputs, and no value # the reform's policy produced. assert arm.drop_computed_arrays() == 0 + for simulation in (reformed, arm): + assert_holders_suit_their_variables(simulation) expected = values(plain) for name, simulation in (("reform", reformed), ("baseline arm", arm)): for variable, array in values(simulation).items(): @@ -190,3 +227,44 @@ def test_situation_arms_match_a_simulation_with_no_reform(): for simulation in (plain, reformed, reformed.baseline): state = simulation.calculate("state_code", YEAR).decode_to_str() assert state.tolist() == ["NY"] + + +def test_the_arm_calculates_what_the_reform_stores_for_all_time(): + """New York's first family enters with a $500 subsidy that the reform + stores for all time. The baseline's yearly ``child_care_subsidies`` cannot + use that storage, so the arm drops the input and pays what a simulation + with no reform pays. Keeping it gave the arm $500 as S0 where baseline + policy pays $13,916.40, and left the reform's expense $13,416.40 low.""" + plain = Microsimulation(dataset=dataset((NEW_YORK,), FAMILIES[:1])) + reformed = Microsimulation( + dataset=dataset((NEW_YORK,), FAMILIES[:1], eternal_subsidy=500), + reform=StoreSubsidiesForAllTime, + ) + arm = reformed.baseline + for simulation in (reformed, arm): + assert_holders_suit_their_variables(simulation) + + def value(simulation, variable): + return np.asarray(simulation.calculate(variable, YEAR), dtype=float) + + baseline_subsidy = value(plain, "child_care_subsidies") + assert baseline_subsidy[0] > 500 + np.testing.assert_allclose( + value(arm, "child_care_subsidies"), baseline_subsidy, atol=TOLERANCE + ) + np.testing.assert_allclose(value(reformed, "child_care_subsidies"), 500) + expense = "spm_unit_capped_work_childcare_expenses" + np.testing.assert_allclose( + value(arm, expense), value(plain, expense), atol=TOLERANCE + ) + # The dataset rule moves the reported amount by the change in subsidy. + expected = np.minimum( + value(reformed, "spm_unit_work_expenses") + + np.maximum( + value(reformed, "spm_unit_pre_subsidy_childcare_expenses") + - (500 - baseline_subsidy), + 0, + ), + value(reformed, "spm_unit_head_spouse_earned_cap"), + ) + np.testing.assert_allclose(value(reformed, expense), expected, atol=TOLERANCE) diff --git a/policyengine_us/tests/core/test_spm_system.py b/policyengine_us/tests/core/test_spm_system.py index ee343a4c1b4..864ec5f4a08 100644 --- a/policyengine_us/tests/core/test_spm_system.py +++ b/policyengine_us/tests/core/test_spm_system.py @@ -856,7 +856,9 @@ def test_subsampling_never_changes_which_policy_a_holder_belongs_to( belongs to that policy. The rebuilt baseline arm has no baseline of its own, holds nothing that only the reform defines, and keeps no holder the reform built for its own definition of a variable: here, yearly storage - for the baseline's ``ETERNITY`` ``is_household_head``. + for the baseline's ``ETERNITY`` ``is_household_head``. Construction branches + the arm again after it moves inputs, and that arm holds to the same + invariant before any subsampling. Core 3.33.1 and later rebuild from recorded inputs without exporting calculated values as inputs; earlier versions also exported calculated @@ -872,6 +874,11 @@ def test_subsampling_never_changes_which_policy_a_holder_belongs_to( reform=SUBSAMPLED_REFORMS[reform], spm={"geography_kind": "national"}, ) + # Core lists input variables by asking for every variable's holder, so the + # redefining reform gets an empty yearly holder for is_household_head. The + # arm branched after the moves copies it and must drop it. + for simulation in (changed, changed.baseline): + assert_bound_to_own_policy(simulation) policies = { "reform": changed.tax_benefit_system, "baseline": changed.baseline.tax_benefit_system, diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py index 0d98c64d0b3..49ee176948b 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py @@ -11,9 +11,11 @@ class spm_unit_capped_work_childcare_expenses(Variable): "Over a dataset, the child care part keeps the reported amount under " "baseline policy and moves by the change in subsidy under a reform, " "which it reads from the reform's baseline arm. Core builds that arm " - "only for a reform passed as reform=. A reformed system passed as " - "tax_benefit_system=, or a reform applied with apply_reform after " - "construction, gets no arm, so the expense keeps the reported amount." + "only for a reform passed as reform=, and apply_reform builds none. A " + "simulation built with reform= keeps its arm, under baseline policy, " + "through any reform applied later. A reformed system passed as " + "tax_benefit_system=, or apply_reform on a simulation built without " + "reform=, leaves no arm, so the expense keeps the reported amount." ) reference = "https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf#page=17" diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py index d68a9287ca0..9cd621dac43 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_pre_subsidy_childcare_expenses.py @@ -7,8 +7,10 @@ class spm_unit_pre_subsidy_childcare_expenses(Variable): label = "SPM unit pre subsidy child care expenses" documentation = ( "In a household calculation, the price of care before any subsidy. " - "Datasets fill it from the ASEC's SPM_CHILDCAREXPNS, which holds what " - "parents reported paying after any subsidy." + "Datasets fill it from the ASEC's SPM_CHILDCAREXPNS, what parents " + "reported spending on child care, which " + "spm_unit_capped_work_childcare_expenses reads as what they paid after " + "any subsidy." ) definition_period = YEAR uprating = "gov.bls.cpi.cpi_u" From 13fc027c39929d43dd655e701127a99d95744189 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 12:21:07 -0500 Subject: [PATCH 11/12] Read household_child_care_subsidies in the SPM invariant tests PolicyEngine/policyengine-us#9503 lists household_child_care_subsidies in every household_state_benefits block in place of the per-state entries. It counts each subsidy up to the family's pre-subsidy bill. The differential test's helper looked only for child_care_subsidies or the state's own variable, so after the merge it returned 0 while household_net_income counted the subsidy, and test_spm_and_household_measures_agree would miss by that amount. - subsidy_counted_by_household_measure now reads whichever child care variable the list names: household_child_care_subsidies, the aggregate, the state's variable or a program that variable adds. It fails if the list names two. - test_no_subsidy_is_a_spm_resource also checks that no SPM benefit list names household_child_care_subsidies. - test_household_measure_counts_each_subsidy_once checks that every household_state_benefits block from 2024 names household_child_care_subsidies once and no other subsidy variable or program. Adding co_ccap_subsidy to the 2026 household block fails the new test, and adding household_child_care_subsidies to spm_unit_benefits fails the SPM check. Co-Authored-By: Claude Opus 5.5 --- .../test_spm_child_care_subsidy_invariants.py | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index b10bc5f7c0a..478cbb0a855 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -34,7 +34,8 @@ 5. No program is counted twice. From 2024, no *_child_care_subsidies variable, and no program one of them adds, appears in spm_unit_benefits or in a reform's copy of that list, and all of them reach child_care_subsidies. - Before 2024 the expense has no formula, so Colorado's, Washington's and West + household_state_benefits counts them only through + household_child_care_subsidies. Before 2024 the expense has no formula, so Colorado's, Washington's and West Virginia's subsidies stay resources and count once, as on main. The Tlaib reform copies carry only Colorado's in those years, also as on main. 6. Differential. With real programs turned on and off, the expense falls by @@ -225,10 +226,19 @@ def state_subsidy_adds(): return {name: list(system.variables[name].adds) for name in STATE_SUBSIDIES} -def test_no_subsidy_is_a_spm_resource(): - subsidy_variables = set(STATE_SUBSIDIES) | {"child_care_subsidies"} +def all_subsidy_variables(): + """Every variable that pays a state subsidy or sums them.""" + variables = set(STATE_SUBSIDIES) | { + "child_care_subsidies", + "household_child_care_subsidies", + } for programs in state_subsidy_adds().values(): - subsidy_variables.update(programs) + variables.update(programs) + return variables + + +def test_no_subsidy_is_a_spm_resource(): + subsidy_variables = all_subsidy_variables() lists = { "baseline spm_unit_benefits": benefits_lists_in(BASELINE_SPM_BENEFITS_SOURCE)[ 0 @@ -249,6 +259,23 @@ def test_no_subsidy_is_a_spm_resource(): ) +def test_household_measure_counts_each_subsidy_once(): + # Property 6 subtracts what household net income counts, so the household + # list has to count each subsidy once too. + others = all_subsidy_variables() - {"household_child_care_subsidies"} + blocks = yaml.load( + ( + PACKAGE / "parameters/gov/household/household_state_benefits.yaml" + ).read_text(), + yaml.BaseLoader, + )["values"] + assert "2024-01-01" in blocks + for start, members in blocks.items(): + if start >= "2024": + assert members.count("household_child_care_subsidies") == 1, start + assert not others & set(members), (start, sorted(others & set(members))) + + def test_every_state_subsidy_is_in_the_programs_list(): variables_dir = PACKAGE / "variables/gov/states" defined = {path.stem for path in variables_dir.rglob("*_child_care_subsidies.py")} @@ -324,15 +351,23 @@ def change(variable): def subsidy_counted_by_household_measure(simulation, state): - """The subsidy household_state_benefits sums, whether it lists the state's - variable or the national aggregate.""" + """The subsidy household_state_benefits sums, through whichever variable it + lists: household_child_care_subsidies, the aggregate, the state's own + variable or a program that variable adds.""" listed = simulation.tax_benefit_system.parameters( f"{YEAR}-01-01" ).gov.household.household_state_benefits state_variable = f"{state.lower()}_child_care_subsidies" - if "child_care_subsidies" in listed or state_variable in listed: - return float(simulation.calculate("child_care_subsidies", YEAR).sum()) - return 0.0 + candidates = { + "household_child_care_subsidies", + "child_care_subsidies", + state_variable, + *state_subsidy_adds()[state_variable], + } + counted = sorted(candidates & set(listed)) + # Listing two of them would count the subsidy twice. + assert len(counted) <= 1, counted + return sum(float(simulation.calculate(name, YEAR).sum()) for name in counted) @settings(max_examples=12, deadline=None) From 5a6557a92a0e921c0016b9c4adec1a7478c5789e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 3 Nov 2026 14:43:40 -0500 Subject: [PATCH 12/12] Set again the inputs a reform stores apart from baseline policy Sol's r7 review found that dropping a holder the reform built with other ETERNITY storage also drops inputs that baseline policy can hold. With a yearly $500 subsidy for 2026 in the dataset both runs read, under a reform that stores child_care_subsidies for all time, the arm calculated $13,916.40 as S0 and the reform's expense came out $27,416.40 instead of $14,000.00. A reform simulation now keeps each input whose variable baseline policy defines in the same entity but with other ETERNITY storage. Every place that drops such holders (the constructor's arm, the rebuilt arm, a subsample's arm and the baseline measurement branch) then sets those inputs again, skipping any the baseline's definition can't hold. subsample carries them over to the sample by person ID, and keeps them when it fails before core rebuilds. Also: #9503's program-list test checked no block on this branch and now checks every block from 2024; invariant 9 states its price condition; the changelog names the replay. Co-Authored-By: Claude Opus 5.5 --- changelog.d/spm-child-care-subsidies.fixed.md | 2 +- policyengine_us/spm.py | 160 +++++++++++++++++- policyengine_us/system.py | 9 +- .../core/test_reform_baseline_arm_inputs.py | 134 +++++++++++++-- ...child_care_subsidies_household_benefits.py | 13 +- .../test_spm_child_care_subsidy_invariants.py | 12 +- .../behavioral_response_measurements.py | 2 + 7 files changed, 306 insertions(+), 26 deletions(-) diff --git a/changelog.d/spm-child-care-subsidies.fixed.md b/changelog.d/spm-child-care-subsidies.fixed.md index 5f55e8d652c..36eea7c754b 100644 --- a/changelog.d/spm-child-care-subsidies.fixed.md +++ b/changelog.d/spm-child-care-subsidies.fixed.md @@ -1 +1 @@ -Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense in household calculations, keep the ASEC's reported spending as that expense over a dataset while a reform moves it by the change in subsidy, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. Rebuild a reform's baseline arm without the holders the reform built for its own definition of a variable in another entity or with other ETERNITY storage, as core's own branch does. +Count every state child care subsidy in the Supplemental Poverty Measure from 2024 by netting it from the SPM child care expense in household calculations, keep the ASEC's reported spending as that expense over a dataset while a reform moves it by the change in subsidy, and add the District of Columbia, Tennessee and Texas subsidies to the 2024 program list. Rebuild a reform's baseline arm without the holders the reform built for its own definition of a variable in another entity or with other ETERNITY storage, as core's own branch does, then set again each input a dataset or set_input gave the reform for such a variable, wherever the baseline's definition can hold it. diff --git a/policyengine_us/spm.py b/policyengine_us/spm.py index 94d547c331e..83024d05ec4 100644 --- a/policyengine_us/spm.py +++ b/policyengine_us/spm.py @@ -12,7 +12,9 @@ from functools import lru_cache from inspect import ismethod, signature +import numpy as np from policyengine_core import periods as periods_ +from policyengine_core.errors import PeriodMismatchError from policyengine_core.parameters import Parameter, ParameterScale from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_core.taxbenefitsystems import TaxBenefitSystem @@ -90,6 +92,12 @@ # branch and invalidation paths below handle them explicitly. BEHAVIORAL_RESPONSE_CACHE_ATTR = "_behavioral_response_measurements" +# Where a reform simulation keeps, as given, each input whose variable baseline +# policy stores apart from it, for its baseline arm to set again +# (``SPMSimulationMixin._keep_input_for_baseline_policy``). Each simulation and +# branch has its own list. +KEPT_BASELINE_INPUTS_ATTR = "_inputs_kept_for_baseline_policy" + COUNTY_INPUT_FIX = ( 'send county_fips as a five-digit string (for example "06037"), or select ' 'geography_kind="national" in the spm configuration' @@ -765,6 +773,18 @@ def pin_spm_policy(system): ) +def _stored_apart(variable, other): + """Whether two definitions of a variable share an entity but only one of + them is stored for ``ETERNITY``.""" + return ( + variable is not None + and other is not None + and variable.entity.key == other.entity.key + and (variable.definition_period == periods_.ETERNITY) + != (other.definition_period == periods_.ETERNITY) + ) + + class SPMSimulationMixin: """Keep forecast selection and receipts private to each simulation.""" @@ -775,6 +795,8 @@ def __init__(self, *args, **kwargs): if baseline is not None: baseline._drop_holders_built_for_another_policy() self._rebind_holders() + if baseline is not None: + baseline._set_inputs_kept_for_baseline_policy(self) # Core sets ``trace`` before this simulation owns its policy state. self._isolate_parameter_tracing() # Every input path - situation builder, dataset loader, direct @@ -853,6 +875,8 @@ def _drop_holders_built_for_another_policy(self): ``ETERNITY`` variable, therefore cannot hold the baseline's definition. Drop it with its recorded inputs, as core 3.32.21 and later do in ``_bind_to_tax_benefit_system``; core 3.32.8 keeps it. + ``_set_inputs_kept_for_baseline_policy`` then sets again each input + the source gave that the baseline's definition can hold. Only a baseline arm does this. ``apply_reform`` keeps a simulation's own inputs, as core's does. @@ -877,6 +901,114 @@ def _drop_holders_built_for_another_policy(self): name for name in self.input_variables if name not in dropped ] + def _keep_input_for_baseline_policy(self, variable_name, period, value): + """Keep an input whose variable baseline policy stores apart from this one. + + A reform's baseline arm drops each holder the reform built with storage + for (or not for) ``ETERNITY`` that the baseline's variable lacks, and + the holder's inputs go with it. An ``ETERNITY`` holder keeps no + period, so it cannot give back a yearly input: under a reform that + stores ``child_care_subsidies`` for all time, the arm calculated the + 2026 subsidy a dataset supplied, and S0 moved by thousands of dollars + with no change in the subsidy paid. So keep each such input as given, + for the arm to set under baseline policy. A holder in another entity + holds values the baseline's entity cannot take, so this keeps none of + those. + + Core's builder sets a situation's inputs on their holders directly, + not through ``set_input``, so this keeps none of them, and the arm + calculates such a variable as core's own arm does. The household rule + for the SPM expense reads no baseline subsidy, so what the arm pays + never reaches a situation's SPM expense. + """ + if getattr(self, "reform", None) is None: + # Only a simulation built with ``reform=`` has a baseline arm. + return + baseline_policy = getattr(self, "default_tax_benefit_system_instance", None) + if baseline_policy is None or not _stored_apart( + self.tax_benefit_system.variables.get(variable_name), + baseline_policy.variables.get(variable_name), + ): + return + count = self.get_variable_population(variable_name).count + # A copy, broadcast as a population takes a scalar input, so a later + # write to the caller's array cannot change what the arm sets. + kept = np.array(np.broadcast_to(value, count, subok=True), subok=True) + self.__dict__.setdefault(KEPT_BASELINE_INPUTS_ATTR, []).append( + (variable_name, period, kept) + ) + + def _set_inputs_kept_for_baseline_policy(self, source): + """Set the inputs ``source`` kept for this simulation's policy. + + ``source`` is the simulation this baseline arm, or baseline measurement + branch, was branched from. Each input it kept belongs to a variable it + stores apart from this policy, so this simulation dropped the holder. + Setting each input again, in the order ``source`` received them, gives + this simulation what a simulation with no reform loads from the same + inputs. This policy cannot hold an ``ETERNITY`` input for a yearly + variable, so that input stays out and this simulation calculates the + variable. + """ + variables = self.tax_benefit_system.variables + source_variables = source.tax_benefit_system.variables + for variable_name, period, value in source.__dict__.get( + KEPT_BASELINE_INPUTS_ATTR, () + ): + if not _stored_apart( + source_variables.get(variable_name), variables.get(variable_name) + ): + continue + try: + self.set_input(variable_name, period, value.copy()) + except PeriodMismatchError: + continue + + def _kept_inputs_on_person_rows(self): + """Each kept input with its values on person rows. + + Core's subsampling exports a group's value on each member's row, so + this does the same. + """ + person_key = self.persons.entity.key + rows = [] + for variable_name, period, value in self.__dict__.get( + KEPT_BASELINE_INPUTS_ATTR, () + ): + entity_key = self.tax_benefit_system.variables[variable_name].entity.key + if entity_key != person_key: + value = self.populations[entity_key].project(value) + rows.append((variable_name, period, entity_key, value)) + return rows + + def _keep_inputs_for_sampled_rows(self, rows, person_ids): + """Keep the inputs on ``rows`` for the people a subsample retained. + + Core retains people by their IDs, as ``_select_subsample_sources`` in + ``tools/social_security_inputs.py`` reads them, and its flat-file + loader gives a group its first member's value, so this does the same. + These replace what the rebuild kept for the same variables and join + what it kept for any other. + """ + positions = {person_id: index for index, person_id in enumerate(person_ids)} + retained = np.array( + [positions[person_id] for person_id in self.persons.ids], dtype=int + ) + person_key = self.persons.entity.key + kept = [] + for variable_name, period, entity_key, values in rows: + values = values[retained] + if entity_key != person_key: + values = self.populations[entity_key].value_from_first_person(values) + kept.append((variable_name, period, values)) + carried = {variable_name for variable_name, *_ in rows} + recorded = [ + entry + for entry in self.__dict__.get(KEPT_BASELINE_INPUTS_ATTR, ()) + if entry[0] not in carried + ] + self.__dict__[KEPT_BASELINE_INPUTS_ATTR] = kept + recorded + def apply_reform(self, reform): policy = self.tax_benefit_system detaching = getattr(policy, "detaching_parameters", None) @@ -1015,6 +1147,11 @@ def clone(self, debug=False, trace=False, clone_tax_benefit_system=True): # other inputs reused them, and what it measured reached this # simulation. A clone measures its own (``get_branch`` seeds a branch). cloned.__dict__.pop(BEHAVIORAL_RESPONSE_CACHE_ATTR, None) + # It shared the kept inputs the same way, so an input set on either + # would reach the other's baseline arm. Each keeps its own list. + kept = self.__dict__.get(KEPT_BASELINE_INPUTS_ATTR) + if kept is not None: + cloned.__dict__[KEPT_BASELINE_INPUTS_ATTR] = list(kept) return cloned def _invalidate_all_caches(self): @@ -1099,7 +1236,26 @@ def _simulation_family(self): yield from branch._simulation_family() def subsample(self, *args, **kwargs): - result = super().subsample(*args, **kwargs) + # Core rebuilds this simulation from the inputs its holders record, and + # an ETERNITY holder records no year: a yearly input the reform stores + # for all time comes back as one input for all time, which baseline + # policy cannot hold. So carry the kept inputs over to the sample as + # given, in place of what the rebuild keeps for their variables. + kept = self._kept_inputs_on_person_rows() + person_ids = tuple(self.persons.ids) if kept else () + dataset = self.dataset + previous = self.__dict__.pop(KEPT_BASELINE_INPUTS_ATTR, None) + try: + result = super().subsample(*args, **kwargs) + except BaseException: + # Core checks its arguments and draws the sample before it sets + # the sample's dataset and rebuilds. A failure before then leaves + # this simulation as it was, so it keeps what it kept. + if previous is not None and self.dataset is dataset: + self.__dict__[KEPT_BASELINE_INPUTS_ATTR] = previous + raise + if kept: + self._keep_inputs_for_sampled_rows(kept, person_ids) baseline = self.branches.get("baseline") if baseline is not None: # Core rebuilds the baseline arm by branching this simulation, so @@ -1117,6 +1273,7 @@ def subsample(self, *args, **kwargs): baseline.baseline = None baseline._drop_holders_built_for_another_policy() baseline._rebind_holders() + baseline._set_inputs_kept_for_baseline_policy(self) baseline._isolate_parameter_tracing() # Core hands the rebuilt baseline arm the previous arm's policy and so # its provider, which recorded the counties the previous arm read. @@ -1138,6 +1295,7 @@ def set_input(self, variable_name, period, value): "Use primitive inputs and retain observed outputs under report-only names." ) result = super().set_input(variable_name, period, value) + self._keep_input_for_baseline_policy(variable_name, period, value) # Core snapshots input_variables during construction, but later # explicit inputs must also survive counterfactual branches. Keep # only stored inputs; neutralized and expired writes are ignored. diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 1f4a94f14b2..e61f87bb0c4 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -269,7 +269,9 @@ def _rebuild_baseline_branch(simulation): ``subsample`` does, gives the baseline the same inputs as the reform, except those the reform stored for its own definition of a variable in another entity or with other ``ETERNITY`` storage, which core's branch - dropped too. + dropped too. Of those, the baseline then sets again each input the + reform received that the baseline's definition can hold, such as a yearly + subsidy the reform stores for all time. """ baseline = simulation.branches.pop("baseline", None) if baseline is None: @@ -286,9 +288,12 @@ def _rebuild_baseline_branch(simulation): # use, restore the other bindings and remove inputs that exist only in # the reform. A kept holder would hand the baseline the reform's input: # under a reform that stores ``child_care_subsidies`` for all time, the - # baseline read that input in place of its own formula. + # baseline read that input in place of its own formula. A dropped holder + # takes its inputs with it, so the baseline sets again each one it can + # hold: dropping a yearly 2026 subsidy left the baseline calculating one. rebuilt._drop_holders_built_for_another_policy() rebuilt._rebind_holders() + rebuilt._set_inputs_kept_for_baseline_policy(simulation) rebuilt._isolate_parameter_tracing() simulation.baseline = rebuilt diff --git a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py index 1406a05ea0e..344df4cba76 100644 --- a/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py +++ b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py @@ -12,18 +12,25 @@ ``system._rebuild_baseline_branch`` now branches the arm again after the moves. Like core's branch, it drops each holder the reform built in another entity or with other ``ETERNITY`` storage than the baseline's variable has. -A kept holder hands the arm the reform's input in place of its formula. +A kept holder hands the arm the reform's input in place of its formula. A +dropped holder takes its inputs with it, so the arm then sets again each input +the reform received for a variable it stores apart from baseline policy, +wherever the baseline's definition can hold that input. Without that step, a +yearly subsidy the reform stores for all time left the arm calculating one. Invariants: under a reform that changes nothing, the reform and its baseline arm compute what a simulation with no reform computes, for every household, whether a situation or a dataset of raw columns supplies the inputs. Under any -reform, each holder in either arm suits that arm's definition of its variable. +reform, each holder in either arm suits that arm's definition of its variable, +and the arm reads each dataset input that baseline policy can hold as a +simulation with no reform reads it, before and after subsampling. """ from itertools import product import numpy as np import pandas as pd +import pytest from policyengine_core.data import Dataset from policyengine_core.periods import ETERNITY from policyengine_core.reforms import Reform @@ -31,6 +38,10 @@ from policyengine_us import Microsimulation, Simulation from policyengine_us.entities import SPMUnit +from policyengine_us.variables.gov.simulation.behavioral_response_measurements import ( + BASELINE_BEHAVIORAL_RESPONSE_MEASUREMENT_BRANCH, + _baseline_measurement_branch, +) YEAR = 2026 TOLERANCE = 0.01 @@ -83,7 +94,16 @@ def apply(self): self.update_variable(child_care_subsidies) -def dataset(states=STATE_FIPS, families=FAMILIES, eternal_subsidy=None): +def dataset( + states=STATE_FIPS, + families=FAMILIES, + eternal_subsidy=None, + yearly_subsidies=None, + weights=None, +): + """A flat file with one household per state and family. ``yearly_subsidies`` + maps a year to each household's child care subsidy, and ``weights`` gives + each household's weight.""" columns = {} def add(name, parent, child): @@ -109,9 +129,12 @@ def add(name, parent, child): add(f"childcare_attending_days_per_month__{YEAR}", 0, 21) add(f"spm_unit_pre_subsidy_childcare_expenses__{YEAR}", expenses, expenses) add(f"state_fips__{YEAR}", fips, fips) - add(f"household_weight__{YEAR}", 1, 1) + weight = 1 if weights is None else weights[unit] + add(f"household_weight__{YEAR}", weight, weight) if eternal_subsidy is not None: add("child_care_subsidies__eternity", eternal_subsidy, eternal_subsidy) + for year, subsidies in (yearly_subsidies or {}).items(): + add(f"child_care_subsidies__{year}", subsidies[unit], subsidies[unit]) return Dataset.from_dataframe(pd.DataFrame(columns), time_period=YEAR) @@ -157,10 +180,13 @@ def situation(): } +def value(simulation, variable, year=YEAR): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + def values(simulation): return { - variable: np.asarray(simulation.calculate(variable, YEAR), dtype=float) - for variable in PRE_RESPONSE + OUTPUTS + variable: value(simulation, variable) for variable in PRE_RESPONSE + OUTPUTS } @@ -230,11 +256,12 @@ def test_situation_arms_match_a_simulation_with_no_reform(): def test_the_arm_calculates_what_the_reform_stores_for_all_time(): - """New York's first family enters with a $500 subsidy that the reform - stores for all time. The baseline's yearly ``child_care_subsidies`` cannot - use that storage, so the arm drops the input and pays what a simulation - with no reform pays. Keeping it gave the arm $500 as S0 where baseline - policy pays $13,916.40, and left the reform's expense $13,416.40 low.""" + """New York's first family enters with a $500 subsidy for all time, which + the reform stores for all time. The baseline's yearly + ``child_care_subsidies`` can hold no input for all time, so the arm drops + the input and pays what a simulation with no reform pays. Keeping it gave + the arm $500 as S0 where baseline policy pays $13,916.40, and left the + reform's expense $13,416.40 low.""" plain = Microsimulation(dataset=dataset((NEW_YORK,), FAMILIES[:1])) reformed = Microsimulation( dataset=dataset((NEW_YORK,), FAMILIES[:1], eternal_subsidy=500), @@ -244,9 +271,6 @@ def test_the_arm_calculates_what_the_reform_stores_for_all_time(): for simulation in (reformed, arm): assert_holders_suit_their_variables(simulation) - def value(simulation, variable): - return np.asarray(simulation.calculate(variable, YEAR), dtype=float) - baseline_subsidy = value(plain, "child_care_subsidies") assert baseline_subsidy[0] > 500 np.testing.assert_allclose( @@ -268,3 +292,85 @@ def value(simulation, variable): value(reformed, "spm_unit_head_spouse_earned_cap"), ) np.testing.assert_allclose(value(reformed, expense), expected, atol=TOLERANCE) + + +def test_the_arm_sets_again_the_yearly_subsidies_the_reform_stores_for_all_time(): + """Both runs read the same dataset, which gives New York's first family a + $400 child care subsidy in 2025 and $500 in 2026. The reform stores + ``child_care_subsidies`` for all time, so it keeps one of them, and the + arm drops that holder. The arm then sets both yearly inputs again and + reads each year's as a simulation with no reform does. Before, it + calculated the 2026 subsidy, and S0 rose to $13,916.40 though the subsidy + paid had not changed.""" + subsidies = {2025: [400], YEAR: [500]} + plain = Microsimulation( + dataset=dataset((NEW_YORK,), FAMILIES[:1], yearly_subsidies=subsidies) + ) + reformed = Microsimulation( + dataset=dataset((NEW_YORK,), FAMILIES[:1], yearly_subsidies=subsidies), + reform=StoreSubsidiesForAllTime, + ) + arm = reformed.baseline + for simulation in (reformed, arm): + assert_holders_suit_their_variables(simulation) + for year, subsidy in subsidies.items(): + for simulation in (plain, arm): + np.testing.assert_allclose( + value(simulation, "child_care_subsidies", year), subsidy + ) + # The reform's holder keeps the input it received last, for all time. + np.testing.assert_allclose(value(reformed, "child_care_subsidies", year), 500) + # S1 equals S0, so the reform reports the expense baseline policy reports. + expense = "spm_unit_capped_work_childcare_expenses" + for simulation in (reformed, arm): + np.testing.assert_allclose( + value(simulation, expense), value(plain, expense), atol=TOLERANCE + ) + + +def test_the_baseline_measurement_sets_again_the_yearly_subsidies(): + """The behavioural-response measurement branches the reform under baseline + policy and drops the same holder, so it too sets each yearly input + again.""" + subsidies = {2025: [400], YEAR: [500]} + reformed = Microsimulation( + dataset=dataset((NEW_YORK,), FAMILIES[:1], yearly_subsidies=subsidies), + reform=StoreSubsidiesForAllTime, + ) + try: + branch = _baseline_measurement_branch(reformed) + assert_holders_suit_their_variables(branch) + for year, subsidy in subsidies.items(): + np.testing.assert_allclose( + value(branch, "child_care_subsidies", year), subsidy + ) + finally: + reformed.branches.pop(BASELINE_BEHAVIORAL_RESPONSE_MEASUREMENT_BRANCH, None) + + +def test_subsampling_keeps_each_yearly_subsidy_for_the_arm(): + """Core subsamples by rebuilding the simulation from what its holders + record, and the reform's holder for all time records neither year. The + sample keeps only the second household, whose inputs the arm then reads + for each year, as before subsampling. A subsample that fails before core + rebuilds anything leaves the simulation's inputs, and those it kept, in + place.""" + subsidies = {2025: [400, 600], YEAR: [500, 700]} + reformed = Microsimulation( + dataset=dataset( + (NEW_YORK,), FAMILIES, yearly_subsidies=subsidies, weights=(0, 1) + ), + reform=StoreSubsidiesForAllTime, + ) + with pytest.raises(ValueError, match="Either n or frac"): + reformed.subsample() + reformed.subsample(n=1) + arm = reformed.baseline + assert reformed.calculate("household_id", YEAR).values.tolist() == [1] + for simulation in (reformed, arm): + assert_holders_suit_their_variables(simulation) + for year, subsidy in subsidies.items(): + np.testing.assert_allclose( + value(arm, "child_care_subsidies", year), subsidy[1:] + ) + np.testing.assert_allclose(value(reformed, "child_care_subsidies", year), 700) diff --git a/policyengine_us/tests/test_child_care_subsidies_household_benefits.py b/policyengine_us/tests/test_child_care_subsidies_household_benefits.py index e2742f0a3e6..77d7fab991d 100644 --- a/policyengine_us/tests/test_child_care_subsidies_household_benefits.py +++ b/policyengine_us/tests/test_child_care_subsidies_household_benefits.py @@ -56,9 +56,16 @@ def test_latest_blocks_list_every_state_program(): ) } assert len(defined) == 51 - for start, programs in blocks(PROGRAMS).items(): - if start >= "2025": - assert set(programs) == defined, start + # Every block from 2024 on lists all 51 programs. With no such block the + # loop would check nothing and pass. + latest = { + start: programs + for start, programs in blocks(PROGRAMS).items() + if start >= "2024" + } + assert latest + for start, programs in latest.items(): + assert set(programs) == defined, start def net_income_and_benefits(state, year, program, amount): diff --git a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py index 478cbb0a855..05188f32603 100644 --- a/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -68,11 +68,13 @@ without a child in the unit, and W and C come from the policy simulated. A policy that pays the same subsidy and leaves W and C alone leaves the expense where baseline policy put it. -9. Differential. Give a dataset the amount the household rule says a unit pays - at baseline policy. Then the two rules give the same expense under any - policy. With the same input and real programs, repealing a subsidy moves - SPM net income by the same amount under both rules, unless the earnings cap - binds under either. +9. Differential. Where the subsidy baseline policy pays does not exceed the + price, give a dataset the amount the household rule says a unit pays at + baseline policy. Then the two rules give the same expense under any + policy. A larger subsidy leaves the unit paying nothing, which hides the + price from the dataset rule. With the same input and real programs, + repealing a subsidy moves SPM net income by the same amount under both + rules, unless the earnings cap binds under either. """ from functools import cache diff --git a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index 328d88cf0a3..99b7e4f5d80 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -95,8 +95,10 @@ def _baseline_measurement_branch(simulation): branch.baseline = None # The copied holders were built for reform policy; its entities or # ETERNITY storage may differ from the baseline's, as after subsampling. + # Dropping one drops its inputs, so set again those baseline policy holds. branch._drop_holders_built_for_another_policy() branch._rebind_holders() + branch._set_inputs_kept_for_baseline_policy(simulation) drop_inherited_values(branch) branch._isolate_parameter_tracing() # The policy copy's receipts list the counties the baseline read; record