Skip to content

Gate Head Start benefits out of net income by default - #9259

Open
MaxGhenis wants to merge 1 commit into
mainfrom
head-start-net-income-gate
Open

Gate Head Start benefits out of net income by default#9259
MaxGhenis wants to merge 1 commit into
mainfrom
head-start-net-income-gate

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Fixes #9192. Companion data-side record: PolicyEngine/microcosm#593.

What

Moves head_start and early_head_start out of the always-on gov.household.household_benefits list into a new household_head_start_benefits wrapper gated by gov.simulation.include_head_start_benefits_in_net_income (default false) — the exact pattern already used for medicaid/msp/chip/aca via household_health_benefits and include_health_benefits_in_net_income.

Why

takes_up_early_head_start_if_eligible is a bare input defaulting true, and early_head_start pays state spending/enrollment (≈$21k per enrollee) to every taker. On any dataset that does not store the flag, every modeled-eligible person (age < 3 or pregnant, in the Head Start income/categorical domain) takes up: on the certified populace 2024 artifact this computes $112.3B across 5.16M persons — ≈29.6× the engine's own spending parameters ($3.796B / 180,873 enrollment) — and all of it lands in household_net_income. populace adjudicated the flag as permanently unseedable (no individual-level source exists in SIPP/ASEC/PUF; ACF counts are turnover/capacity — see populace#593), so the fix belongs at this aggregation boundary. Census SPM draws the same line: school meals and WIC count as resources, Head Start does not (these programs were never in spm_unit_benefits).

Default false is defensible beyond the phantom: even a correctly-seeded enrollee's per-slot cost is not cash-equivalent household resources, which is the same rationale as the health gate.

Changes

  • parameters/gov/household/household_head_start_benefits.yaml — new list (head_start, early_head_start)
  • parameters/gov/simulation/include_head_start_benefits_in_net_income.yaml — new switch, default false
  • variables/household/income/household/household_head_start_benefits.py — gated wrapper (health-wrapper clone)
  • parameters/gov/household/household_benefits.yaml — wrapper replaces the raw pair in both dated brackets
  • BOOST + legacy ECPA static benefit lists gain the wrapper so the flag-on path composes under those reforms (their static lists predate Add head_start and early_head_start variables to household_benefits parameter list #4918 and never contained head_start, so default-path reform behavior is unchanged; the broader staleness of those static copies vs. the live list is pre-existing and out of scope)
  • Tests: new household_head_start_benefits.yaml (flag off/on, 2022-bracket coverage, cross-switch independence with the health flag in both directions); default-exclusion cases added to the household_benefits and household_net_income baselines

What does not change

  • The program variables themselves: head_start/early_head_start compute exactly as before and remain individually queryable (partner analytics-coverage contracts pass unchanged).
  • SPM poverty: these programs were never in spm_unit_benefits.
  • Flag-on behavior reproduces today's inclusion for analyses that want it.

Tests run locally

  • tests/policy/baseline/household — 425 passed (includes new wrapper suite and extended aggregate baselines)
  • tests/policy/contrib/congress/tlaib — 22 passed
  • tests/policy/baseline/gov/hhs/head_start — 40 passed
  • tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/childcare — 47 passed
  • ruff format / ruff check clean

🤖 Generated with Claude Code

Move head_start and early_head_start from the always-on
gov.household.household_benefits list into a
household_head_start_benefits wrapper gated by
gov.simulation.include_head_start_benefits_in_net_income (default
false), mirroring the health benefits switch. On data without seeded
take-up flags the engine's default-true flags value every eligible
person at per-enrollee cost (~$112B vs $3.8B actual on 2024
microdata), inflating household_net_income.

Also adds the wrapper to the BOOST and legacy ECPA static benefit
lists so the flag-on path composes under those reforms.

Fixes #9192

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (f2f4de8) to head (3231eaf).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #9259   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            1         1           
  Lines           26        13   -13     
  Branches         0         1    +1     
=========================================
- Hits            26        13   -13     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@DTrim99

DTrim99 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Program Review — multi-agent audit

PR #9259 — Gate Head Start out of household net income by default
Author: Max Ghenis · Type: infrastructure / aggregation-boundary correction (not a statute-value change) · CI: 33/33 pass · Branch: 1 ahead / 23 behind main · No PDF/source-document audit (structural change; rationale is microsim + SPM convention, no statute values).

Scope

The PR moves head_start and early_head_start out of the raw household_benefits list and into a new household_head_start_benefits wrapper variable, gated by a new switch include_head_start_benefits_in_net_income (bool, default false), mirroring the established household_health_benefits / include_health_benefits_in_net_income gate exactly.

Why it matters: takes_up_early_head_start_if_eligible (and its head_start twin) is a bare Person bool input with default_value = True and is never dataset-seeded (populace#593 adjudicated the flag permanently unseedable). So every modeled-eligible person's flag reads True, and head_start/early_head_start value every eligible person at full per-slot average cost ($21k for early Head Start). At merge-base those per-slot costs flowed straight into household_benefits → household_net_income — a **$112.3B phantom** in aggregate net income. Default-false gates that phantom out; flag-on exactly reproduces prior inclusion.

Files: 2 new params (household_head_start_benefits.yaml list @ 2022-01-01; include_head_start_benefits_in_net_income.yaml switch @ 0000-01-01), 1 edited param (household_benefits.yaml, both brackets), 1 new variable (household_head_start_benefits.py), 3 tests, 2 reform edits (BOOST boost_middle_class_tax_credit.py, ECPA end_child_poverty_act.py), 1 changelog fragment (.added).

Note: branch is 23 behind main — a rebase before merge is advisable.

Verified correct (design + code + core test)

Design — all 7 checks PASS:

  1. Pattern fidelity — the wrapper is a line-for-line structural clone of household_health_benefits.py (value_type=float, entity=Household, unit=USD, definition_period=YEAR; identical gated formula). Switch and list params mirror the health templates exactly.
  2. Phantom is real and gated at the right layer — mechanism confirmed end-to-end (unseedable take-up default-true → full per-slot cost per eligible person). Because populace#593 makes the flag permanently unseedable, per-slot cost cannot be made cash-equivalent, so gating at the net-income aggregation boundary (not the take-up/formula layer) is the correct fix — identical justification to the health gate.
  3. Phantom fully closedhead_start/early_head_start monetary values reach net income only via household_benefits (single path, add() dedupes). Grep confirms no other net-income-reaching aggregate (household_market_income, spm_unit_benefits, household_state_benefits) injects a Head Start dollar. Other head_start references are demographic-only (is_enrolled_in_head_start, state child-care flags).
  4. SPM genuinely unaffectedhead_start/early_head_start were never in spm_unit_benefits; SPM poverty is untouched (corroborated by test Case 5 + household_benefits default-exclusion case).
  5. Default-false defensible on the merits — a per-slot in-kind service cost is not spendable household cash, same category as Medicaid/CHIP cost already gated out.
  6. Reforms unchanged on the default path — BOOST/ECPA static BENEFITS lists never contained raw head_start/early_head_start, so default-path reform output was never affected; the wrapper appends cleanly and returns 0 while the flag is default-false. Flag-on composes Head Start values into the reform benefit total (add() dedupes, no double-count).
  7. No reinventionhousehold_head_start_benefits follows the household_<category>_benefits convention; direct analog of the health wrapper, no duplicate concept.

Flag-on reproduces prior inclusion exactly (test Case 2: flag true → 29,000), so any consumer wanting Head Start in net income is fully mitigated.

Code — PASS:

  • Entity Household on the wrapper matches the template and how household_benefits.yaml consumes it; underlying head_start/early_head_start are Person/YEAR/float, aggregated Person→Household correctly via add(household, period, ...).
  • Both household_benefits.yaml brackets (2022-01-01 and 2024-01-01) swap the raw head_start+early_head_start pair for the single wrapper with no other member dropped, added, or reordered (pre-existing ak_energy_relief / commodity_supplemental_food_program bracket differences untouched).
  • Switch pattern, gated add()-not-adds() formula, and both reform single-line appends (BOOST line 77, ECPA line 113) are clean. No hardcodes, no TODO/placeholder.

Core test — PASS: default-exclusion (the fix) is pinned at all three layers — wrapper (Case 1 = 0), household_benefits (new case: wrapper 0 + benefits 0), and household_net_income (new case: head_start 8_000 + early_head_start 21_000, all other components forced to 0 → net income 0). The net-income case would fail (would read 29,000) if the gate were removed — a genuine regression catcher. Cross-switch independence is proven in both directions (Cases 4 & 5). Changelog fragment present.

Critical (must fix)

None.

Should address

  1. Flag-on net-income test (HIGH) — top gap. Inclusion is only proven at the household_benefits layer (Case 2). There is no case with include_head_start_benefits_in_net_income: true asserting household_net_income actually rises — so the whole point of the switch (net-income inclusion) is unverified end-to-end, and a future household_benefits → household_net_income adds-chain refactor that dropped the wrapper would not be caught.

    • File: policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml
    • Add: flag true, head_start: 8_000 + early_head_start: 21_000, all other net-income components 0 → household_net_income: 29_000. The symmetric partner to the default case already present.
  2. Reform-composition flag-on test (MEDIUM). BOOST and ECPA both gained the wrapper, but no test exercises the flag-on-under-reform path. Benefit-list membership is easy to typo/drop and nothing currently catches it. Add at least one BOOST or ECPA integration case with the flag on, asserting the Head Start value flows into that reform's net income.

  3. Multi-child entity-aggregation test (MEDIUM). Every case is single-enrollee shorthand; no case proves per-person head_start + early_head_start sum to Household via add(). Values are per-enrollee, so a 2+ enrolled-child household is the realistic case. Add a 2-child case (each child head_start N, flag on → household wrapper 2N); the health template has one.

  4. economy: false switch-metadata parity (LOW-MED, shared with the health template). The new switch omits economy: false — but so does the health template it faithfully mirrors (metadata is only unit: bool + label). Per repo convention, take-up/simulation switches that don't affect the web app should carry economy: false. This is a shared pre-existing parity gap, not introduced here — add it to both include_head_start_benefits_in_net_income.yaml and include_health_benefits_in_net_income.yaml, or neither. Do not change one without the other.

Suggestions

  • Confirm pre-2022 flag-on behavior. The wrapper list starts 2022-01-01 while underlying head_start/early_head_start data begins ~2021-09-01. Because the containing household_benefits brackets also start 2022-01-01, no behavior changes and real analyses are 2022+; but a direct flag-on read for a pre-2022 period may resolve the param before its first bracket. Worth a one-line author confirmation that intended pre-2022 behavior is a clean 0, not an error.
  • 0000-01-01 default-date convention on the switch matches the health template exactly — noted, no action.
  • Changelog type (added vs fixed). The change reads like a behavior correction (fixed) but mechanically introduces a new switch + gated variable, so added is defensible and consistent with how the health gate would be categorized. Note only.
  • (Cosmetic) Case 3 name references "the 2022 benefit list bracket," but there is only one dated bracket; the test is functionally correct (verifies flag-on in 2023). Optional rename.

Validation summary

Check Result
Design / rationale (7 checks) PASS
Phantom closure (single net-income path, fully closed) PASS
Entity / period aggregation (Person→Household, YEAR) PASS
List-bracket integrity (both brackets, no member dropped) PASS
Reform composition (default-path unchanged; wrapper appended) PASS (flag-on path untested)
Test coverage PASS core; HIGH gap = flag-on net-income; MEDIUM = reform + multi-child
CI 33/33 PASS

Review severity: APPROVE

Zero criticals; the design is fully verified including phantom closure and SPM neutrality; the core fix (default exclusion from net income) is robustly pinned at three layers with a genuine regression catcher. Remaining items are test-symmetry hardening (flag-on inclusion, reform composition, multi-child aggregation) plus a shared metadata parity nit — none blocking.

Next steps

  • Run /fix-pr 9259 to add the flag-on net-income test, one reform flag-on integration test, and the multi-child aggregation test; optionally apply economy: false to both switch params together.
  • Rebase onto main (branch is 23 behind).

🤖 Generated by /review-program (Claude Code)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants