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 new file mode 100644 index 00000000000..36eea7c754b --- /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 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/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/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py index d4a2face50a..9ededad2209 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 @@ -110,7 +110,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", @@ -147,6 +146,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 4b6552e52e3..211ca348ac8 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", @@ -159,6 +158,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 7dea7b0defc..bc74edb5935 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -138,7 +138,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", @@ -176,6 +175,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 3ae570a9999..46dd3d210b8 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", @@ -131,6 +126,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/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 10639e64381..e61f87bb0c4 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -266,7 +266,12 @@ 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. 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: @@ -279,8 +284,16 @@ 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. 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 new file mode 100644 index 00000000000..344df4cba76 --- /dev/null +++ b/policyengine_us/tests/core/test_reform_baseline_arm_inputs.py @@ -0,0 +1,376 @@ +"""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 +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. 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. 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 +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, +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 +from policyengine_core.variables import Variable + +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 +# 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 + + +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, + 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): + columns.setdefault(name, []).extend([parent, child]) + + 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"): + 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) + 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) + + +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 value(simulation, variable, year=YEAR): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def values(simulation): + return { + variable: value(simulation, variable) for variable in PRE_RESPONSE + OUTPUTS + } + + +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 + 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 + 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(): + 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"] + + +def test_the_arm_calculates_what_the_reform_stores_for_all_time(): + """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), + reform=StoreSubsidiesForAllTime, + ) + arm = reformed.baseline + for simulation in (reformed, arm): + assert_holders_suit_their_variables(simulation) + + 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) + + +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/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/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..328c171fc6b --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_spm_child_care_reported_expense.py @@ -0,0 +1,116 @@ +"""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, 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. +""" + +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/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..0a569868a10 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_child_care_subsidies_integration.yaml @@ -0,0 +1,366 @@ +# 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 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. + +- 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 + +- 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 cd2e0be8f63..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 @@ -105,3 +105,155 @@ 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 + +- 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 2c8a14090b0..acab06757a3 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 @@ -163,6 +163,52 @@ 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 + # A one-adult household with enough earnings to rule out every means-tested # benefit, so each case isolates one payment. Each case also checks that # household_benefits counts the same payment. 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..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 @@ -211,3 +211,49 @@ 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 + +- 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 cf8c3331d02..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 @@ -263,3 +263,49 @@ 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 + +- 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 1d9d3f1e04e..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 @@ -159,3 +159,49 @@ 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 + +- 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 b1e56bc9dde..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 @@ -181,3 +181,53 @@ # 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 + +- 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_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 new file mode 100644 index 00000000000..05188f32603 --- /dev/null +++ b/policyengine_us/tests/test_spm_child_care_subsidy_invariants.py @@ -0,0 +1,647 @@ +"""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) + +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: + +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. 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. + 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 + 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 +out-of-pocket expenses, so a subsidy can cut them by more than the SPM expense +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. + +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, 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: + + 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, 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. 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 + +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") +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] + + +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}, + } + 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} + 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=INCOME_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 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(): + 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 + ][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_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")} + 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 + + +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(situation): + 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, 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" + 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) +@given( + state=st.sampled_from(STATES), + 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(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=INCOME_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, 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( + -subsidy, abs=TOLERANCE + ) + assert change("household_net_income") > 0 + assert change("spm_unit_net_income") == pytest.approx( + change("household_net_income"), abs=INCOME_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(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=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), + "policy_work": st.integers(0, 6_000), + "policy_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, + "policy_work": 100, + "policy_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, + "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. 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["policy_work"], + unit["policy_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. 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) + del situation["people"]["parent"]["weeks_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/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index 780033b204e..99b7e4f5d80 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 @@ -97,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 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..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 @@ -7,9 +7,50 @@ 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=, 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" 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. + simulation = spm_unit.simulation + if simulation.is_over_dataset: + # 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: + 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 + np.maximum(childcare_expenses, 0), earned_cap) + 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..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 @@ -5,6 +5,13 @@ 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, 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" unit = USD diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py index 0295c517ff8..4372721d174 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 @@ -58,19 +58,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", @@ -111,4 +106,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)