Skip to content

Apply SNAP work-requirement disqualifications per-person (7 CFR 273.7(f)(1))#8148

Open
PavelMakarchuk wants to merge 4 commits intomainfrom
fix-8139-snap-work-requirements-per-person
Open

Apply SNAP work-requirement disqualifications per-person (7 CFR 273.7(f)(1))#8148
PavelMakarchuk wants to merge 4 commits intomainfrom
fix-8139-snap-work-requirements-per-person

Conversation

@PavelMakarchuk
Copy link
Copy Markdown
Collaborator

Closes #8139.

Summary

Replace the SPM-unit-wide aggregation in `meets_snap_work_requirements` with a per-person aggregation (`spm_unit.any` instead of `spm_unit.sum(~x) == 0`), so a single non-compliant member no longer denies the entire SNAP unit.

Regulatory basis

  • 7 CFR § 273.7(f)(1) (general work requirement): "A nonexempt individual who refuses or fails without good cause to comply with SNAP work requirements is ineligible to participate in SNAP." — individual disqualification.
  • 7 CFR § 273.24(b) (ABAWD time limit): "Individuals are not eligible to participate in SNAP as a member of any household if the individual received SNAP benefits for more than three countable months..." — individual disqualification; ABAWD has no HoH option.
  • 7 CFR § 273.7(f)(5): narrow state option to disqualify the entire household when the HoH fails general work requirements, bounded to ≤180 days. Per the USDA SNAP State Options Report, 16th Edition (June 2024, p. 20), elected by only 8 jurisdictions: AZ, FL, MA, MN, MS, TX, VA, VI.

What changed

Before:

return spm_unit.sum(~meets_work_requirements_person) == 0

All members must pass → any single failure denies the whole unit. This matched no jurisdiction.

After:

return spm_unit.any(meets_work_requirements_person)

Unit is eligible as long as at least one member meets requirements or is exempt. Matches the default rule in the 45 non-electing jurisdictions exactly.

Why this is the right first pass

Per the issue's analysis, the prior behavior over-applied § 273.7(f)(5) across four dimensions simultaneously — trigger (any member vs. HoH), requirement type (both general + ABAWD vs. general only), state coverage (all vs. 8), duration (permanent vs. ≤180 days). The `any` aggregation fixes the 45 non-electing jurisdictions exactly and substantially reduces over-denial in the 8 electing jurisdictions.

The § 273.7(f)(5) state-option treatment (HoH-specific, general-only, 180-day-bounded, 8 jurisdictions) is not parameterized in this PR; deferred as a follow-up per the issue's Option B.

Reproduction verified

From the issue's reproduction scenario (CA household, two adults, Adult A works 40 hrs/wk, Adult B not working, no exemption):

Variable Before After
`meets_snap_work_requirements` `False` (bug) `True`

Test plan

  • 3 new multi-adult test cases added (one-passes-one-fails → eligible, both-fail → ineligible, both-pass → eligible regression guard)
  • 9 / 9 `meets_snap_work_requirements.yaml` tests pass
  • 290 / 290 full SNAP baseline tests pass
  • `make format` clean

Scope excluded (follow-ups)

🤖 Generated with Claude Code

Per 7 CFR 273.7(f)(1) (general work requirement) and 273.24(b) (ABAWD
time limit), the default rule is individual disqualification — the
non-compliant member is excluded from the SNAP unit, but the remaining
members continue to receive SNAP (with income and resources of the
excluded member prorated under 7 CFR 273.11(c)(2)). Only the narrow
7 CFR 273.7(f)(5) state option — elected by 8 jurisdictions per the
USDA SNAP State Options Report 16th Edition — permits household-wide
disqualification when the head of household fails the general work
requirement, bounded to at most 180 days.

The prior aggregation `spm_unit.sum(~meets) == 0` treated any single
member's failure as a household-wide denial, which matches no
jurisdiction (over-applied the 273.7(f)(5) option to all states, to
non-HoH members, to ABAWD failures, and without the 180-day bound).

This commit switches to `spm_unit.any(meets)` — the unit is eligible
as long as at least one member meets requirements (or is exempt) —
which matches the default rule in the 45 non-electing jurisdictions
exactly and substantially reduces over-denial in the 8 electing
jurisdictions. The state-option treatment per 273.7(f)(5) is not yet
parameterized and is deferred to a follow-up.

Adds three test cases exercising the multi-adult scenarios from the
issue (one-passes-one-fails → eligible, both-fail → ineligible,
both-pass → eligible regression guard).

Closes #8139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@PavelMakarchuk PavelMakarchuk requested a review from hua7450 April 23, 2026 21:59
@PavelMakarchuk PavelMakarchuk marked this pull request as ready for review April 23, 2026 21:59
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 23, 2026

Codecov Report

❌ Patch coverage is 98.46154% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.25%. Comparing base (20705b0) to head (ca8130b).
⚠️ Report is 21 commits behind head on main.

Files with missing lines Patch % Lines
.../income/deductions/snap_child_support_deduction.py 50.00% 1 Missing ⚠️
...gross/snap_child_support_gross_income_deduction.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #8148       +/-   ##
===========================================
+ Coverage   85.36%   96.25%   +10.89%     
===========================================
  Files           3       12        +9     
  Lines          41      187      +146     
  Branches        2        0        -2     
===========================================
+ Hits           35      180      +145     
- Misses          6        7        +1     
Flag Coverage Δ
unittests 96.25% <98.46%> (+10.89%) ⬆️

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

☔ View full report in Codecov by Sentry.
📢 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.

PavelMakarchuk and others added 3 commits April 23, 2026 18:11
Extends the per-person disqualification fix to match the existing
student / immigration exclusion pattern:

- New `is_snap_work_requirements_disqualified` person-level variable
  captures the work-requirement failure test (previously inlined).
- `meets_snap_work_requirements` simplifies to
  `spm_unit.any(~disqualified)`, delegating the per-person logic.
- `snap_unit_size` now subtracts work-requirement-disqualified members
  from the unit size (joining the existing subtraction of ineligible
  students and immigration-ineligible members).

This matches how PE already handles per-person exclusions for
students and immigration: eligibility requires at least one eligible
member, and the unit size used for benefit calculation shrinks by
the count of excluded members.

Income proration per 7 CFR 273.11(c)(2) / (c)(3) for excluded
members is NOT yet modeled — a pre-existing architectural limitation
that affects students and immigration-ineligible members equally.
PR #6526 attempted a comprehensive proration implementation and was
closed without merge; deferred to a follow-up.

All 290 SNAP baseline tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Infrastructure for implementing pro rata income and deduction
treatment for SNAP members disqualified under 273.11(c)(2) / (c)(3):

- `is_snap_disqualified_prorated` (Person, bool): union of ineligible
  students and immigration-ineligible members.
- `snap_income_share` (Person, float): per-person multiplier that is
  1.0 for eligible members and entirety-disqualified (c1) members,
  and (eligible_count / total_size) for prorated-disqualified (c2/c3)
  members.

No behavior change yet — these variables are not yet consumed.
Follow-up commits wire them into earned/unearned income and
deduction aggregations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1(c))

Adds pro rata treatment for members disqualified under 7 CFR
273.11(c)(2) and (c)(3) — ineligible students and immigration-
ineligible members — so that only the eligible members' share of
such members' income and prorated expenses count toward the SNAP
unit's benefit calculation.

Under (c)(2)/(c)(3), for each prorated-disqualified member, their
income is divided evenly among all household members and the share
that would have gone to eligible members is counted. The share of
the ineligible member's income that would have gone to other
ineligible members is dropped. Work-requirement disqualification
continues to fall under (c)(1) entirety treatment (full income
counted, member excluded from unit size only) — handled by the
earlier commit.

Architecture:

- `snap_prorated_earned_income_reduction` (SPMUnit) — subtracts the
  non-counted share of prorated-disqualified members' earned
  income. Covers Person-level employment_income and self-employment
  income with the SPM-level expense deduction attributed pro rata
  across self-employed members' gross incomes.
- `snap_prorated_unearned_income_reduction` (SPMUnit) — subtracts
  the non-counted share for Person-level unearned income sources
  (ssi, social_security, pensions, UI, disability, workers' comp,
  retirement distributions, child support received, alimony). SPM/
  TaxUnit-level sources (tanf, general_assistance, rental_income)
  are excluded from proration as they can't be attributed to
  specific members.
- `snap_earned_income` and `snap_unearned_income` subtract the
  respective reductions from the raw aggregation.
- `snap_child_support_expense` (SPMUnit) — new variable aggregating
  child_support_expense × snap_income_share across members. Used by
  both `snap_child_support_deduction` and
  `snap_child_support_gross_income_deduction`.

Behavior is unchanged for households without prorated-disqualified
members (share = 1.0 for all members → reduction = 0).

Adds 6 test cases in snap_prorated_income.yaml covering:
- No prorated members (regression)
- 4-person household with 1 ineligible student
- 2-person household with 1 ineligible student (1/2 share)
- Unearned income proration for ineligible student
- Child support expense proration
- Work-requirement-disqualified entirety treatment (no reduction)

All 299 SNAP baseline tests pass (293 previous + 6 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@hua7450 hua7450 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Program Review

Read-only review of the diff at HEAD. Code-only PR — no parameter values to PDF-audit. CI shows 1 failing job: Full Suite - Rest (Python + variables/ YAMLs) — root-caused below.

Critical (Must Fix)

  1. snap_unit_size cascade is the CI failure root cause. Adding is_snap_work_requirements_disqualified to the ineligible mask in snap_unit_size.py:23 collapses unit size for any pre-existing fixture where adults have no weekly_hours_worked_before_lsr set (default 0) and aren't is_disabled. Confirmed-likely failures: snap.yaml:42-118 (3 cases), integration.yaml:1-26 & :101-128 (family of 4), snap_net_income.yaml:1-86. Recommended fix: revert the size-exclusion; keep the per-person fix scoped to meets_snap_work_requirements, which is what the PR body advertises.

  2. snap_prorated_income.yaml fixtures are internally inconsistent with the new code. All adults in the 4 fixtures have 0 hours and no children → all is_snap_work_requirements_disqualified=True → asserted snap_unit_size: 3 actually computes to 0; asserted earned-income reductions don't hold.

  3. ABAWD time-limit failure regulatorily belongs in PRORATED, not entirety. 7 CFR 273.11(c)(2)'s heading explicitly lists "ineligible ABAWDs"; the PR collapses (f)(1) general-work-req and (b) ABAWD into one boolean and then size-excludes both. ABAWD members with significant earned income are over-counted under entirety treatment.

  4. Ineligible students are excluded under 273.11(d), not prorated under (c)(2)/(c)(3). 7 CFR 273.5(d) cross-references to 273.11(d), which fully excludes student income (cash transfers excepted). The PR routes them through is_snap_disqualified_prorated (c)(2)/(c)(3) proration math. Directionally better than master, but 7 files cite the wrong subsection.

  5. snap_prorated_unearned_income_reduction silently drops 5 Person-level unearned sources. The hardcoded PERSON_LEVEL_UNEARNED_SOURCES omits dividend_income, interest_income, miscellaneous_income, rental_income, general_assistance — all confirmed entity = Person in the codebase. The docstring's claim that general_assistance/rental_income are SPM/tax-unit-level is incorrect.

  6. snap_prorated_earned_income_reduction:38, :48 mixes YEAR-period and MONTH-period variables. snap_self_employment_expense_deduction is definition_period = YEAR but called with period (MONTH) — auto /12 conversion happens to cancel in the ratio, but the period mix is implicit and re-implements snap_earned_income_person's math.

  7. No standalone test for is_snap_work_requirements_disqualified — the core new Person-level variable is only exercised indirectly through SPMUnit-level tests. Per PolicyEngine standards, formula variables with no unit test are a critical gap.

  8. absolute_error_margin: 0.9 on a boolean output. meets_snap_work_requirements.yaml:43, :66 (Cases 5-6) — with margin ≥ 0.5 on a boolean, true and false become indistinguishable; the test is non-functional.

  9. Scope creep relative to PR body. The body says "(c)(2) deferred as follow-up", but the diff introduces 6 new variables (is_snap_disqualified_prorated, is_snap_work_requirements_disqualified, snap_income_share, snap_child_support_expense, snap_prorated_earned_income_reduction, snap_prorated_unearned_income_reduction) and rewires 5 more. Either split into two PRs or update body + changelog.

Should Address

  • is_snap_disqualified_prorated.py:8 cites (c)(2)/(c)(3) for students — wrong subsection (students are 273.11(d)).
  • (c)(2) citations on alien-only proration variables are dead code: snap_prorated_*_reduction.py, snap_child_support_*.py, snap_earned_income.py, snap_unearned_income.py, snap_income_share.py. Drop #c_2, keep #c_3.
  • meets_snap_work_requirements.py:14-22 cites "8 jurisdictions / 180 days" without the SNAP State Options Report 16th Ed. reference (#page=22).
  • Bare cfr/text/7/273.7 and cfr/text/7/273.24 URLs should append #f_1 and #b.
  • snap_child_support_expense.py missing the 7 USC 2014(e)(4) statute citation that its sibling files include.
  • snap_unit_size.py inline comment cites (c)(2) but the formula behavior is (c)(1) entirety / 273.24(b).
  • Dependent-care, shelter, 20% earned-income deductions for prorated members aren't implemented (273.11(c)(2) requires them).
  • No standalone tests for is_snap_disqualified_prorated, snap_income_share, snap_prorated_*_reduction, snap_child_support_expense.
  • Missing edge cases in meets_snap_work_requirements.yaml: 1-adult-fails, 3+-adult scaling, all-exempt unit, state-option carve-out marker.
  • Missing edge cases in snap_prorated_income.yaml: all-prorated household, empty-size SPM branch, combined work-req + prorated, self-employment proration.
  • No integration test asserting non-zero snap benefit for the #8139 regression.
  • is_snap_work_requirements_disqualified recomputes no_dependent_child per Person; broadcast suggests an SPMUnit helper.
  • State-option carve-out comment lacks issue/TODO reference.
  • is_snap_disqualified_prorated and is_snap_work_requirements_disqualified use documentation instead of reference for URLs.

Suggestions

  • Rename is_snap_disqualified_proratedis_snap_prorated_member (the treatment is prorated, not the person).
  • Rename snap_prorated_*_reduction*_proration_exclusion (reads more naturally).
  • Add SNAP State Options Report 16th Ed. citation with #page=NN if the (f)(5) claim depends on it.
  • snap_income_share.py:30: defensive comment that share is bounded [0, 1].
  • Test naming: cases 7-9 use adult_a/adult_b; testing-patterns skill prefers person1/person2.

CI Status

  • Failing: Full Suite - Rest — caused by (1) snap_unit_size cascade and (2) snap_prorated_income.yaml self-inconsistency.
  • Passing: lint, all baseline shards, all contrib shards, smoke imports, codecov, changelog fragment.

Validation Summary

Check Result
Regulatory Accuracy 3 critical, 5 should address
Reference Quality 4 critical, 4 should address
Code Patterns 5 critical, 6 should address
Test Coverage 4 critical (incl. CI), 5 gaps
PDF Value Audit Skipped — code-only PR
CI Status 1 job failing (caused by this PR)

Review Severity: REQUEST_CHANGES

Justification: failing CI directly caused by this PR's snap_unit_size change, self-inconsistent new fixtures, regulatory misclassification of ABAWD and ineligible students, silent under-application of proration to 5 unearned sources, period mismatch in self-employment math, missing standalone test for the core new variable, non-functional boolean tests, and major scope creep relative to the PR body.

🤖 Posted via /review-program

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SNAP work requirements applied as household-wide disqualification instead of per-person exclusion

2 participants