From 71c500192670752ba439ab0c20066a198b4d8cd5 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 23 Apr 2026 17:56:28 -0400 Subject: [PATCH 1/7] Apply SNAP work-requirement disqualifications individually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...snap-work-requirements-per-person.fixed.md | 1 + .../meets_snap_work_requirements.yaml | 71 +++++++++++++++++++ .../meets_snap_work_requirements.py | 24 ++++++- 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md diff --git a/changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md b/changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md new file mode 100644 index 00000000000..692e952fca3 --- /dev/null +++ b/changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md @@ -0,0 +1 @@ +Apply SNAP work-requirement disqualifications at the individual level per 7 CFR 273.7(f)(1) and 273.24(b), instead of denying the entire SPM unit when any single member fails. diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml index f1664d91c34..358257bc067 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml @@ -83,3 +83,74 @@ state_code: TX output: meets_snap_work_requirements: false + +- name: Case 7, two adults, one passes one fails, unit eligible (per-person disqualification). + # Per 7 CFR 273.7(f)(1), the non-compliant adult is individually + # disqualified and excluded; the remaining member continues. + period: 2024-01 + input: + people: + adult_a: + age: 35 + weekly_hours_worked_before_lsr: 40 + adult_b: + age: 30 + weekly_hours_worked_before_lsr: 0 + spm_units: + spm_unit: + members: [adult_a, adult_b] + tax_units: + tax_unit: + members: [adult_a, adult_b] + households: + household: + members: [adult_a, adult_b] + state_code: CA + output: + meets_snap_work_requirements: true + +- name: Case 8, two adults, both fail, unit ineligible. + period: 2024-01 + input: + people: + adult_a: + age: 35 + weekly_hours_worked_before_lsr: 0 + adult_b: + age: 30 + weekly_hours_worked_before_lsr: 0 + spm_units: + spm_unit: + members: [adult_a, adult_b] + tax_units: + tax_unit: + members: [adult_a, adult_b] + households: + household: + members: [adult_a, adult_b] + state_code: CA + output: + meets_snap_work_requirements: false + +- name: Case 9, two adults both pass, unit eligible (regression guard). + period: 2024-01 + input: + people: + adult_a: + age: 35 + weekly_hours_worked_before_lsr: 40 + adult_b: + age: 30 + weekly_hours_worked_before_lsr: 35 + spm_units: + spm_unit: + members: [adult_a, adult_b] + tax_units: + tax_unit: + members: [adult_a, adult_b] + households: + household: + members: [adult_a, adult_b] + state_code: CA + output: + meets_snap_work_requirements: true diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py index 56aa32bc660..1e7b7449fa7 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py @@ -6,9 +6,26 @@ class meets_snap_work_requirements(Variable): entity = SPMUnit label = "SPM Unit is eligible for SNAP benefits via work requirements" definition_period = MONTH - reference = "https://www.fns.usda.gov/snap/work-requirements" + reference = ( + "https://www.fns.usda.gov/snap/work-requirements", + # 7 CFR 273.7(f)(1) — general work requirement; individual + # disqualification is the default rule. + "https://www.law.cornell.edu/cfr/text/7/273.7", + # 7 CFR 273.24(b) — ABAWD time limit; always individual. + "https://www.law.cornell.edu/cfr/text/7/273.24", + ) def formula(spm_unit, period, parameters): + # Per 7 CFR 273.7(f)(1) and 273.24(b), a non-compliant member is + # individually disqualified and excluded from the SNAP unit. + # Remaining members continue to receive SNAP (with income and + # resources of the excluded member prorated under 7 CFR + # 273.11(c)(2) — proration not yet modeled). Only the narrow + # 7 CFR 273.7(f)(5) state option — elected by 8 jurisdictions + # (AZ, FL, MA, MN, MS, TX, VA, VI) — permits household-wide + # disqualification when the head of household fails the general + # work requirement, bounded to at most 180 days. That option is + # not yet parameterized here. person = spm_unit.members general_work_requirements = person( "meets_snap_general_work_requirements", period @@ -31,4 +48,7 @@ def formula(spm_unit, period, parameters): abawd_work_requirements & general_work_requirements, general_work_requirements, ) - return spm_unit.sum(~meets_work_requirements_person) == 0 + # Unit is eligible as long as at least one member meets + # requirements (or is exempt). Members who fail are individually + # disqualified per 273.7(f)(1) / 273.24(b). + return spm_unit.any(meets_work_requirements_person) From 61a09ea71b3b8e205af1de880815cee30cdbdd3c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 23 Apr 2026 18:10:55 -0400 Subject: [PATCH 2/7] Exclude work-requirement-disqualified members from SNAP unit size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../is_snap_work_requirements_disqualified.py | 42 ++++++++++++++++++ .../meets_snap_work_requirements.py | 44 +++++-------------- .../variables/gov/usda/snap/snap_unit_size.py | 9 +++- 3 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py new file mode 100644 index 00000000000..f68513eb67c --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class is_snap_work_requirements_disqualified(Variable): + value_type = bool + entity = Person + label = "SNAP work requirements disqualified" + documentation = ( + "Whether this person is individually disqualified from the SNAP " + "unit for failing general work requirements or the ABAWD time " + "limit. Per 7 CFR 273.7(f)(1) and 273.24(b), the disqualified " + "member is excluded from the SNAP unit; remaining members " + "continue to receive SNAP." + ) + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.7", + "https://www.law.cornell.edu/cfr/text/7/273.24", + ) + + def formula(person, period, parameters): + general_work_requirements = person( + "meets_snap_general_work_requirements", period + ) + abawd_work_requirements = person("meets_snap_abawd_work_requirements", period) + # Dependent child threshold differs: pre-HR1 (18) vs post-HR1 (14). + hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) + p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold + p_pre = parameters( + "2025-06-01" + ).gov.usda.snap.work_requirements.abawd.age_threshold + dep_threshold = where(hr1_in_effect, p.dependent, p_pre.dependent) + age = person("monthly_age", period) + is_dependent = person("is_tax_unit_dependent", period) + is_child = age < dep_threshold + no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 + meets_work_requirements = where( + no_dependent_child, + abawd_work_requirements & general_work_requirements, + general_work_requirements, + ) + return ~meets_work_requirements diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py index 1e7b7449fa7..d13e57faedc 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py @@ -18,37 +18,15 @@ class meets_snap_work_requirements(Variable): def formula(spm_unit, period, parameters): # Per 7 CFR 273.7(f)(1) and 273.24(b), a non-compliant member is # individually disqualified and excluded from the SNAP unit. - # Remaining members continue to receive SNAP (with income and - # resources of the excluded member prorated under 7 CFR - # 273.11(c)(2) — proration not yet modeled). Only the narrow - # 7 CFR 273.7(f)(5) state option — elected by 8 jurisdictions - # (AZ, FL, MA, MN, MS, TX, VA, VI) — permits household-wide - # disqualification when the head of household fails the general - # work requirement, bounded to at most 180 days. That option is - # not yet parameterized here. + # Remaining members continue to receive SNAP. The unit remains + # eligible on the work-requirement dimension so long as at least + # one member meets requirements or is exempt. + # + # The narrow 7 CFR 273.7(f)(5) state option — elected by 8 + # jurisdictions (AZ, FL, MA, MN, MS, TX, VA, VI) — permitting + # household-wide disqualification when the head of household + # fails the general work requirement, bounded to at most 180 + # days, is not yet parameterized here. person = spm_unit.members - general_work_requirements = person( - "meets_snap_general_work_requirements", period - ) - abawd_work_requirements = person("meets_snap_abawd_work_requirements", period) - # Dependent child threshold differs: pre-HR1 (18) vs post-HR1 (14) - hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) - p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold - # Snapshot pre-HR1 values (last month before 2025-07-04 effective date). - p_pre = parameters( - "2025-06-01" - ).gov.usda.snap.work_requirements.abawd.age_threshold - dep_threshold = where(hr1_in_effect, p.dependent, p_pre.dependent) - age = person("monthly_age", period) - is_dependent = person("is_tax_unit_dependent", period) - is_child = age < dep_threshold - no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 - meets_work_requirements_person = where( - no_dependent_child, - abawd_work_requirements & general_work_requirements, - general_work_requirements, - ) - # Unit is eligible as long as at least one member meets - # requirements (or is exempt). Members who fail are individually - # disqualified per 273.7(f)(1) / 273.24(b). - return spm_unit.any(meets_work_requirements_person) + disqualified = person("is_snap_work_requirements_disqualified", period) + return spm_unit.any(~disqualified) diff --git a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py index d4e9ab9194f..0db01c44ab8 100644 --- a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py +++ b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py @@ -9,13 +9,18 @@ class snap_unit_size(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/7/2014#b", "https://www.law.cornell.edu/uscode/text/7/2015#f", + # 7 CFR 273.11(c)(2) — treatment of disqualified members: the + # individual is excluded from the SNAP unit for size purposes. + "https://www.law.cornell.edu/cfr/text/7/273.11", ) def formula(spm_unit, period, parameters): unit_size = spm_unit("spm_unit_size", period) person = spm_unit.members - ineligible = person("is_snap_ineligible_student", period) | ~person( - "is_snap_immigration_status_eligible", period + ineligible = ( + person("is_snap_ineligible_student", period) + | ~person("is_snap_immigration_status_eligible", period) + | person("is_snap_work_requirements_disqualified", period) ) ineligible_count = spm_unit.sum(ineligible) return max_(unit_size - ineligible_count, 0) From 7f3debb1314bb49af1ca65295a7b7d6e4d9cc30e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 23 Apr 2026 18:37:43 -0400 Subject: [PATCH 3/7] Add scaffolding for SNAP per-person proration (7 CFR 273.11(c)) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../is_snap_disqualified_prorated.py | 25 ++++++++++++++ .../gov/usda/snap/snap_income_share.py | 34 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py create mode 100644 policyengine_us/variables/gov/usda/snap/snap_income_share.py diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py new file mode 100644 index 00000000000..fcc18ab4270 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class is_snap_disqualified_prorated(Variable): + value_type = bool + entity = Person + label = "SNAP disqualified with prorated treatment" + documentation = ( + "Whether this person is excluded from the SNAP unit under the " + "'prorated' treatment of 7 CFR 273.11(c)(2) / (c)(3): the " + "individual's income is divided evenly among all household " + "members and only the share that would have gone to eligible " + "members is counted, while resources continue to count in full. " + "Applies to ineligible students (higher education) and members " + "who are not immigration-status eligible." + ) + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_2" + + def formula(person, period, parameters): + ineligible_student = person("is_snap_ineligible_student", period) + immigration_ineligible = ~person( + "is_snap_immigration_status_eligible", period + ) + return ineligible_student | immigration_ineligible diff --git a/policyengine_us/variables/gov/usda/snap/snap_income_share.py b/policyengine_us/variables/gov/usda/snap/snap_income_share.py new file mode 100644 index 00000000000..f4de4759173 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/snap_income_share.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * + + +class snap_income_share(Variable): + value_type = float + entity = Person + label = "SNAP income share" + documentation = ( + "The fraction of this person's income and prorated deductions " + "that count toward the SNAP unit's calculation, per 7 CFR " + "273.11(c). For prorated-disqualified members (students, " + "immigration-ineligible), this equals (total_size - " + "prorated_count) / total_size so that only the eligible " + "members' share of the member's income is counted. For all " + "other persons (eligible members and entirety-disqualified " + "members under c1), the share is 1.0: their income counts in " + "full." + ) + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(person, period, parameters): + is_prorated = person("is_snap_disqualified_prorated", period) + spm_size = person.spm_unit("spm_unit_size", period) + prorated_count = person.spm_unit.sum(is_prorated) + # Safe division: if spm_size is 0 there is no SPM unit to speak + # of; the share is irrelevant because income aggregations will + # also be zero. Guard to avoid NaN. + safe_size = where(spm_size > 0, spm_size, 1) + eligible_fraction = (safe_size - prorated_count) / safe_size + return where(is_prorated, eligible_fraction, 1.0) From ca8130b8b8d0af33ebc9cfc8c36d5be483333671 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 23 Apr 2026 18:53:42 -0400 Subject: [PATCH 4/7] Implement SNAP per-person income and deduction proration (7 CFR 273.11(c)) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../snap/income/snap_prorated_income.yaml | 165 ++++++++++++++++++ .../is_snap_disqualified_prorated.py | 4 +- .../snap_child_support_deduction.py | 9 +- ...ap_child_support_gross_income_deduction.py | 10 +- .../snap/income/snap_child_support_expense.py | 24 +++ .../usda/snap/income/snap_earned_income.py | 16 +- .../snap_prorated_earned_income_reduction.py | 60 +++++++ ...snap_prorated_unearned_income_reduction.py | 46 +++++ .../usda/snap/income/snap_unearned_income.py | 20 ++- 9 files changed, 338 insertions(+), 16 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml create mode 100644 policyengine_us/variables/gov/usda/snap/income/snap_child_support_expense.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml new file mode 100644 index 00000000000..7a685995840 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml @@ -0,0 +1,165 @@ +# Tests for SNAP income and expense proration under 7 CFR 273.11(c)(2) / (c)(3) +# for members disqualified under the "prorated" treatment (ineligible students, +# immigration-ineligible). For each prorated-disqualified member, their income +# is divided evenly among all household members and only the portion that +# would go to eligible members is counted. + +- name: No prorated-disqualified members — no proration. + period: 2024-01 + input: + people: + a: + age: 40 + employment_income: 24_000 + b: + age: 38 + employment_income: 12_000 + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_earned_income_reduction: 0 + snap_prorated_unearned_income_reduction: 0 + snap_earned_income: 3_000 # $36k annual / 12 + +- name: 4-person household with 1 ineligible student — 1/4 of student's earned income excluded. + period: 2024-01 + input: + people: + a: + age: 40 + employment_income: 24_000 + b: + age: 38 + employment_income: 24_000 + c: + age: 20 + employment_income: 24_000 + d: + age: 19 + employment_income: 24_000 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [a, b, c, d] + tax_units: + tax_unit: + members: [a, b, c, d] + households: + household: + members: [a, b, c, d] + state_code: CA + output: + snap_unit_size: 3 + snap_prorated_earned_income_reduction: 500 # $2k/mo × 1/4 + snap_earned_income: 7_500 # $8k raw − $500 + +- name: 2-person household with 1 ineligible student — 1/2 of their income excluded. + period: 2024-01 + input: + people: + a: + age: 40 + employment_income: 12_000 + b: + age: 19 + employment_income: 12_000 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_unit_size: 1 + snap_prorated_earned_income_reduction: 500 # b's $1k/mo × 1/2 + snap_earned_income: 1_500 # $2k raw − $500 + +- name: Unearned income prorated for ineligible student. + period: 2024-01 + input: + people: + a: + age: 40 + unemployment_compensation: 12_000 + b: + age: 19 + unemployment_compensation: 12_000 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_unearned_income_reduction: 500 # b's $1k × 1/2 + snap_unearned_income: 1_500 # $2k raw − $500 + +- name: Child support expense of ineligible student prorated. + period: 2024-01 + input: + people: + a: + age: 40 + child_support_expense: 2_400 + b: + age: 19 + child_support_expense: 2_400 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + # a: $200/mo × share 1.0 = $200; b: $200/mo × share 0.5 = $100; total $300 + snap_child_support_expense: 300 + +- name: Work-requirement-disqualified member's income counts in full (entirety treatment under (c)(1)). + period: 2024-01 + input: + people: + a: + age: 35 + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + b: + age: 30 + weekly_hours_worked_before_lsr: 0 + employment_income: 24_000 + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_unit_size: 1 # b excluded from size + snap_prorated_earned_income_reduction: 0 # (c)(1) entirety, no reduction + snap_earned_income: 4_000 # full $2k/mo each diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py index fcc18ab4270..75cd084e8ea 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py @@ -19,7 +19,5 @@ class is_snap_disqualified_prorated(Variable): def formula(person, period, parameters): ineligible_student = person("is_snap_ineligible_student", period) - immigration_ineligible = ~person( - "is_snap_immigration_status_eligible", period - ) + immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) return ineligible_student | immigration_ineligible diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py index 3e66ccbe87a..f6079205664 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py @@ -8,12 +8,13 @@ class snap_child_support_deduction(Variable): unit = USD documentation = "Deduction from SNAP gross income for child support payments" definition_period = MONTH - reference = "https://www.law.cornell.edu/uscode/text/7/2014#e_4" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#e_4", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + ) - # Excluding deduction for child support, which is applies to the gross income - # calculation def formula(spm_unit, period, parameters): - child_support = add(spm_unit, period, ["child_support_expense"]) + child_support = spm_unit("snap_child_support_expense", period) gross_income_deduction = spm_unit( "snap_child_support_gross_income_deduction", period ) diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py index ed2ca911305..39ed764ae28 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py @@ -7,13 +7,17 @@ class snap_child_support_gross_income_deduction(Variable): label = "SNAP child support payment deduction from gross income" unit = USD documentation = ( - "Deduction for child support payments when computing SNAP gross income" + "Deduction for child support payments when computing SNAP gross income, " + "using the prorated SNAP child support expense." ) definition_period = MONTH - reference = "https://www.law.cornell.edu/uscode/text/7/2014#e_4" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#e_4", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + ) def formula(spm_unit, period, parameters): - child_support = add(spm_unit, period, ["child_support_expense"]) + child_support = spm_unit("snap_child_support_expense", period) state = spm_unit.household("state_code_str", period) is_deductible = parameters( period diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_child_support_expense.py b/policyengine_us/variables/gov/usda/snap/income/snap_child_support_expense.py new file mode 100644 index 00000000000..4f8a7c2b9c7 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_child_support_expense.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class snap_child_support_expense(Variable): + value_type = float + entity = SPMUnit + label = "SNAP child support expense" + unit = USD + documentation = ( + "Child support payments treated as expenses for SNAP purposes, " + "with prorated-disqualified members' contributions reduced per " + "7 CFR 273.11(c)(2). For eligible and entirety-disqualified " + "members (c1), the full expense counts; for prorated-" + "disqualified members (c2/c3), only the eligible members' " + "share of their expense counts." + ) + definition_period = MONTH + reference = ("https://www.law.cornell.edu/cfr/text/7/273.11#c_2",) + + def formula(spm_unit, period, parameters): + person = spm_unit.members + expense = person("child_support_expense", period) + share = person("snap_income_share", period) + return spm_unit.sum(expense * share) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py index 231f1e5356c..4c15f3f1339 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py @@ -6,8 +6,18 @@ class snap_earned_income(Variable): entity = SPMUnit definition_period = MONTH label = "SNAP earned income" - documentation = "Earned income for calculating the SNAP earned income deduction" - reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_1" + documentation = ( + "Earned income for calculating the SNAP benefit. Reduced by " + "the non-counted share of prorated-disqualified members' " + "earned income per 7 CFR 273.11(c)(2) / (c)(3)." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_1", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + ) unit = USD - adds = ["snap_earned_income_person"] + def formula(spm_unit, period, parameters): + raw = add(spm_unit, period, ["snap_earned_income_person"]) + reduction = spm_unit("snap_prorated_earned_income_reduction", period) + return max_(raw - reduction, 0) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py new file mode 100644 index 00000000000..6191fbf71f6 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py @@ -0,0 +1,60 @@ +from policyengine_us.model_api import * + + +class snap_prorated_earned_income_reduction(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP prorated earned income reduction" + unit = USD + documentation = ( + "The portion of prorated-disqualified members' earned income " + "that should not be counted per 7 CFR 273.11(c)(2) / (c)(3). " + "The regulation divides the ineligible member's income evenly " + "among all household members; only the portion that would go " + "to eligible members is counted. This variable returns the " + "amount to subtract from the raw SPM-unit earned income " + "aggregation. It covers Person-level employment_income and " + "self-employment income with the SPM-level expense deduction " + "attributed pro rata across self-employed members. Zero when " + "there are no prorated-disqualified members." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(spm_unit, period, parameters): + person = spm_unit.members + is_prorated = person("is_snap_disqualified_prorated", period) + countable = person("snap_countable_earner", period) + spm_size = person.spm_unit("spm_unit_size", period) + prorated_count = person.spm_unit.sum(is_prorated) + safe_size = where(spm_size > 0, spm_size, 1) + # Per 273.11(c)(2), divide income evenly across all members; + # count only the eligible share. The excluded share is + # prorated_count / spm_size of the prorated member's income. + exclusion_fraction = prorated_count / safe_size + employment = person("employment_income", period) + self_emp_gross = add( + person, + period, + [ + "self_employment_income_before_lsr", + "sstb_self_employment_income_before_lsr", + ], + ) + spm_self_emp_gross = person.spm_unit.sum(self_emp_gross) + spm_expense = person.spm_unit("snap_self_employment_expense_deduction", period) + # Attribute the SPM-level expense deduction proportionally to + # each member's share of total self-employment gross income. + safe_gross = where(spm_self_emp_gross > 0, spm_self_emp_gross, 1) + attributed_expense = where( + spm_self_emp_gross > 0, + spm_expense * self_emp_gross / safe_gross, + 0, + ) + self_emp_net_person = max_(self_emp_gross - attributed_expense, 0) + person_earned = (employment + self_emp_net_person) * countable + reduction_per_person = person_earned * is_prorated * exclusion_fraction + return spm_unit.sum(reduction_per_person) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py new file mode 100644 index 00000000000..933b2578bf5 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py @@ -0,0 +1,46 @@ +from policyengine_us.model_api import * + + +PERSON_LEVEL_UNEARNED_SOURCES = [ + "ssi", + "social_security", + "pension_income", + "veterans_benefits", + "unemployment_compensation", + "disability_benefits", + "workers_compensation", + "retirement_distributions", + "child_support_received", + "alimony_income", +] + + +class snap_prorated_unearned_income_reduction(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP prorated unearned income reduction" + unit = USD + documentation = ( + "The portion of prorated-disqualified members' Person-level " + "unearned income that should not be counted per 7 CFR " + "273.11(c)(2) / (c)(3). SPM-level and tax-unit-level sources " + "(tanf, general_assistance, rental_income) cannot be " + "attributed to specific persons for proration and are excluded." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(spm_unit, period, parameters): + sources = list(parameters(period).gov.usda.snap.income.sources.unearned) + applicable = [s for s in PERSON_LEVEL_UNEARNED_SOURCES if s in sources] + person = spm_unit.members + is_prorated = person("is_snap_disqualified_prorated", period) + spm_size = person.spm_unit("spm_unit_size", period) + prorated_count = person.spm_unit.sum(is_prorated) + safe_size = where(spm_size > 0, spm_size, 1) + exclusion_fraction = prorated_count / safe_size + person_unearned = add(person, period, applicable) if applicable else 0 + return spm_unit.sum(person_unearned * is_prorated * exclusion_fraction) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py index 0d6b5368aa2..5899a4f7273 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py @@ -6,8 +6,22 @@ class snap_unearned_income(Variable): entity = SPMUnit definition_period = MONTH label = "SNAP unearned income" - documentation = "Unearned income for calculating the SNAP benefit" - reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_2" + documentation = ( + "Unearned income for calculating the SNAP benefit. Reduced by " + "the non-counted share of prorated-disqualified members' " + "Person-level unearned income per 7 CFR 273.11(c)(2) / (c)(3)." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + ) unit = USD - adds = "gov.usda.snap.income.sources.unearned" + def formula(spm_unit, period, parameters): + raw = add( + spm_unit, + period, + list(parameters(period).gov.usda.snap.income.sources.unearned), + ) + reduction = spm_unit("snap_prorated_unearned_income_reduction", period) + return max_(raw - reduction, 0) From bd12fbc8a36205291dac3d47b81e7bac542e2ed0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 10 May 2026 09:38:44 -0400 Subject: [PATCH 5/7] Fix SNAP proration microsim weights --- .../income/snap_prorated_earned_income_reduction.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py index 6191fbf71f6..2256514f5b4 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py @@ -28,8 +28,8 @@ def formula(spm_unit, period, parameters): person = spm_unit.members is_prorated = person("is_snap_disqualified_prorated", period) countable = person("snap_countable_earner", period) - spm_size = person.spm_unit("spm_unit_size", period) - prorated_count = person.spm_unit.sum(is_prorated) + spm_size = np.asarray(spm_unit.project(spm_unit("spm_unit_size", period))) + prorated_count = np.asarray(spm_unit.project(spm_unit.sum(is_prorated))) safe_size = where(spm_size > 0, spm_size, 1) # Per 273.11(c)(2), divide income evenly across all members; # count only the eligible share. The excluded share is @@ -44,14 +44,16 @@ def formula(spm_unit, period, parameters): "sstb_self_employment_income_before_lsr", ], ) - spm_self_emp_gross = person.spm_unit.sum(self_emp_gross) - spm_expense = person.spm_unit("snap_self_employment_expense_deduction", period) + spm_self_emp_gross = np.asarray(spm_unit.project(spm_unit.sum(self_emp_gross))) + spm_expense = np.asarray( + spm_unit.project(spm_unit("snap_self_employment_expense_deduction", period)) + ) # Attribute the SPM-level expense deduction proportionally to # each member's share of total self-employment gross income. safe_gross = where(spm_self_emp_gross > 0, spm_self_emp_gross, 1) attributed_expense = where( spm_self_emp_gross > 0, - spm_expense * self_emp_gross / safe_gross, + self_emp_gross * spm_expense / safe_gross, 0, ) self_emp_net_person = max_(self_emp_gross - attributed_expense, 0) From 0e3c111a34eed4764e9489775877bb2072b37b73 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 10 May 2026 10:16:53 -0400 Subject: [PATCH 6/7] Add SNAP self-employment proration test --- .../snap/income/snap_prorated_income.yaml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml index 7a685995840..5e14ac438b0 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml @@ -87,6 +87,31 @@ snap_prorated_earned_income_reduction: 500 # b's $1k/mo × 1/2 snap_earned_income: 1_500 # $2k raw − $500 +- name: Self-employment expense deduction attributed before proration. + period: 2024-01 + input: + people: + a: + age: 40 + self_employment_income_before_lsr: 12_000 + b: + age: 19 + self_employment_income_before_lsr: 12_000 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_earned_income_reduction: 300 + snap_earned_income: 900 + - name: Unearned income prorated for ineligible student. period: 2024-01 input: From 12eb14f37a0e29d3b3382f637eda775c5edd4807 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 10 May 2026 10:50:11 -0400 Subject: [PATCH 7/7] Address SNAP proration review feedback --- .../is_snap_disqualified_prorated.yaml | 25 +++++ ...s_snap_work_requirements_disqualified.yaml | 15 +++ .../meets_snap_work_requirements.yaml | 2 - .../snap/income/snap_prorated_income.yaml | 106 ++++++++++++++++-- .../is_snap_disqualified_prorated.py | 28 ++++- .../is_snap_work_requirements_disqualified.py | 34 +----- .../meets_snap_work_requirements.py | 24 +++- .../usda/snap/income/snap_earned_income.py | 3 +- .../snap_prorated_earned_income_reduction.py | 2 +- ...snap_prorated_unearned_income_reduction.py | 11 +- .../usda/snap/income/snap_unearned_income.py | 4 +- .../gov/usda/snap/snap_income_share.py | 5 +- .../variables/gov/usda/snap/snap_unit_size.py | 6 +- 13 files changed, 204 insertions(+), 61 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml new file mode 100644 index 00000000000..18d9bc6a6fd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml @@ -0,0 +1,25 @@ +- name: ABAWD time-limit failure receives prorated treatment. + period: 2024-01 + input: + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: false + is_snap_immigration_status_eligible: true + output: + is_snap_disqualified_prorated: true + +- name: Immigration-ineligible member receives prorated treatment. + period: 2024-01 + input: + meets_snap_abawd_work_requirements: true + is_snap_immigration_status_eligible: false + output: + is_snap_disqualified_prorated: true + +- name: Ineligible student does not receive prorated treatment. + period: 2024-01 + input: + is_snap_ineligible_student: true + meets_snap_abawd_work_requirements: true + is_snap_immigration_status_eligible: true + output: + is_snap_disqualified_prorated: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml new file mode 100644 index 00000000000..6b78af03cdd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml @@ -0,0 +1,15 @@ +- name: General work requirement failure is an individual disqualification. + period: 2024-01 + input: + meets_snap_general_work_requirements: false + meets_snap_abawd_work_requirements: true + output: + is_snap_work_requirements_disqualified: true + +- name: ABAWD time-limit failure is not a general work requirement disqualification. + period: 2024-01 + input: + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: false + output: + is_snap_work_requirements_disqualified: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml index 358257bc067..5ab02d3251a 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml @@ -40,7 +40,6 @@ - name: Case 5, CA pre-HR1 age 55 exempt flows through SPM. period: 2026-01 - absolute_error_margin: 0.9 input: people: person1: @@ -63,7 +62,6 @@ - name: Case 6, non-CA post-HR1 age 55 not exempt. period: 2026-01 - absolute_error_margin: 0.9 input: people: person1: diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml index 5e14ac438b0..8c57ca823a9 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml @@ -1,6 +1,6 @@ # Tests for SNAP income and expense proration under 7 CFR 273.11(c)(2) / (c)(3) -# for members disqualified under the "prorated" treatment (ineligible students, -# immigration-ineligible). For each prorated-disqualified member, their income +# for members disqualified under the "prorated" treatment (ABAWDs who exceed +# the time limit, immigration-ineligible members). For each prorated member, income # is divided evenly among all household members and only the portion that # would go to eligible members is counted. @@ -10,9 +10,13 @@ people: a: age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 employment_income: 24_000 b: age: 38 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 employment_income: 12_000 spm_units: spm_unit: @@ -29,23 +33,30 @@ snap_prorated_unearned_income_reduction: 0 snap_earned_income: 3_000 # $36k annual / 12 -- name: 4-person household with 1 ineligible student — 1/4 of student's earned income excluded. +- name: 4-person household with 1 immigration-ineligible member — 1/4 of their earned income excluded. period: 2024-01 input: people: a: age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 employment_income: 24_000 b: age: 38 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 employment_income: 24_000 c: age: 20 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 employment_income: 24_000 d: age: 19 + weekly_hours_worked_before_lsr: 40 employment_income: 24_000 - is_snap_ineligible_student: true + is_snap_immigration_status_eligible: false spm_units: spm_unit: members: [a, b, c, d] @@ -61,17 +72,20 @@ snap_prorated_earned_income_reduction: 500 # $2k/mo × 1/4 snap_earned_income: 7_500 # $8k raw − $500 -- name: 2-person household with 1 ineligible student — 1/2 of their income excluded. +- name: 2-person household with 1 immigration-ineligible member — 1/2 of their income excluded. period: 2024-01 input: people: a: age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 employment_income: 12_000 b: age: 19 + weekly_hours_worked_before_lsr: 40 employment_income: 12_000 - is_snap_ineligible_student: true + is_snap_immigration_status_eligible: false spm_units: spm_unit: members: [a, b] @@ -87,17 +101,51 @@ snap_prorated_earned_income_reduction: 500 # b's $1k/mo × 1/2 snap_earned_income: 1_500 # $2k raw − $500 +- name: ABAWD time-limit failure uses prorated treatment. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + employment_income: 12_000 + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: true + b: + age: 30 + is_snap_immigration_status_eligible: true + employment_income: 12_000 + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_unit_size: 1 + snap_prorated_earned_income_reduction: 500 + snap_earned_income: 1_500 + - name: Self-employment expense deduction attributed before proration. period: 2024-01 input: people: a: age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 self_employment_income_before_lsr: 12_000 b: age: 19 + weekly_hours_worked_before_lsr: 40 self_employment_income_before_lsr: 12_000 - is_snap_ineligible_student: true + is_snap_immigration_status_eligible: false spm_units: spm_unit: members: [a, b] @@ -112,17 +160,20 @@ snap_prorated_earned_income_reduction: 300 snap_earned_income: 900 -- name: Unearned income prorated for ineligible student. +- name: Unearned income prorated for immigration-ineligible member. period: 2024-01 input: people: a: age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 unemployment_compensation: 12_000 b: age: 19 + weekly_hours_worked_before_lsr: 40 unemployment_compensation: 12_000 - is_snap_ineligible_student: true + is_snap_immigration_status_eligible: false spm_units: spm_unit: members: [a, b] @@ -137,17 +188,48 @@ snap_prorated_unearned_income_reduction: 500 # b's $1k × 1/2 snap_unearned_income: 1_500 # $2k raw − $500 -- name: Child support expense of ineligible student prorated. +- name: Previously omitted person-level unearned income is prorated. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + dividend_income: 12_000 + b: + age: 19 + weekly_hours_worked_before_lsr: 40 + dividend_income: 12_000 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_unearned_income_reduction: 500 + snap_unearned_income: 1_500 + +- name: Child support expense of immigration-ineligible member prorated. period: 2024-01 input: people: a: age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 child_support_expense: 2_400 b: age: 19 + weekly_hours_worked_before_lsr: 40 child_support_expense: 2_400 - is_snap_ineligible_student: true + is_snap_immigration_status_eligible: false spm_units: spm_unit: members: [a, b] @@ -168,10 +250,12 @@ people: a: age: 35 + is_snap_immigration_status_eligible: true weekly_hours_worked_before_lsr: 40 employment_income: 24_000 b: age: 30 + is_snap_immigration_status_eligible: true weekly_hours_worked_before_lsr: 0 employment_income: 24_000 spm_units: diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py index 75cd084e8ea..3895c705bc9 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py @@ -7,17 +7,35 @@ class is_snap_disqualified_prorated(Variable): label = "SNAP disqualified with prorated treatment" documentation = ( "Whether this person is excluded from the SNAP unit under the " - "'prorated' treatment of 7 CFR 273.11(c)(2) / (c)(3): the " + "'prorated' treatment of 7 CFR 273.11(c)(2) or (c)(3): the " "individual's income is divided evenly among all household " "members and only the share that would have gone to eligible " "members is counted, while resources continue to count in full. " - "Applies to ineligible students (higher education) and members " + "Applies to members who fail the ABAWD time limit and members " "who are not immigration-status eligible." ) definition_period = MONTH - reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_2" + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) def formula(person, period, parameters): - ineligible_student = person("is_snap_ineligible_student", period) + # Dependent child threshold differs: pre-HR1 (18) vs post-HR1 (14). + hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) + p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold + p_pre = parameters( + "2025-06-01" + ).gov.usda.snap.work_requirements.abawd.age_threshold + dep_threshold = where(hr1_in_effect, p.dependent, p_pre.dependent) + age = person("monthly_age", period) + is_dependent = person("is_tax_unit_dependent", period) + is_child = age < dep_threshold + no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 + abawd_ineligible = ( + no_dependent_child + & person("meets_snap_general_work_requirements", period) + & ~person("meets_snap_abawd_work_requirements", period) + ) immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) - return ineligible_student | immigration_ineligible + return abawd_ineligible | immigration_ineligible diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py index f68513eb67c..0a4cdb4ae76 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py @@ -7,36 +7,12 @@ class is_snap_work_requirements_disqualified(Variable): label = "SNAP work requirements disqualified" documentation = ( "Whether this person is individually disqualified from the SNAP " - "unit for failing general work requirements or the ABAWD time " - "limit. Per 7 CFR 273.7(f)(1) and 273.24(b), the disqualified " - "member is excluded from the SNAP unit; remaining members " - "continue to receive SNAP." + "unit for failing the general work requirements. Per 7 CFR " + "273.7(f)(1), the disqualified member is excluded from the SNAP " + "unit; remaining members continue to receive SNAP." ) definition_period = MONTH - reference = ( - "https://www.law.cornell.edu/cfr/text/7/273.7", - "https://www.law.cornell.edu/cfr/text/7/273.24", - ) + reference = "https://www.law.cornell.edu/cfr/text/7/273.7#f_1" def formula(person, period, parameters): - general_work_requirements = person( - "meets_snap_general_work_requirements", period - ) - abawd_work_requirements = person("meets_snap_abawd_work_requirements", period) - # Dependent child threshold differs: pre-HR1 (18) vs post-HR1 (14). - hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) - p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold - p_pre = parameters( - "2025-06-01" - ).gov.usda.snap.work_requirements.abawd.age_threshold - dep_threshold = where(hr1_in_effect, p.dependent, p_pre.dependent) - age = person("monthly_age", period) - is_dependent = person("is_tax_unit_dependent", period) - is_child = age < dep_threshold - no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 - meets_work_requirements = where( - no_dependent_child, - abawd_work_requirements & general_work_requirements, - general_work_requirements, - ) - return ~meets_work_requirements + return ~person("meets_snap_general_work_requirements", period) diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py index d13e57faedc..721094d1895 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py @@ -10,9 +10,9 @@ class meets_snap_work_requirements(Variable): "https://www.fns.usda.gov/snap/work-requirements", # 7 CFR 273.7(f)(1) — general work requirement; individual # disqualification is the default rule. - "https://www.law.cornell.edu/cfr/text/7/273.7", + "https://www.law.cornell.edu/cfr/text/7/273.7#f_1", # 7 CFR 273.24(b) — ABAWD time limit; always individual. - "https://www.law.cornell.edu/cfr/text/7/273.24", + "https://www.law.cornell.edu/cfr/text/7/273.24#b", ) def formula(spm_unit, period, parameters): @@ -28,5 +28,23 @@ def formula(spm_unit, period, parameters): # fails the general work requirement, bounded to at most 180 # days, is not yet parameterized here. person = spm_unit.members - disqualified = person("is_snap_work_requirements_disqualified", period) + # ABAWD time-limit failures apply only when the household has no + # dependent child under the applicable age threshold. + hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) + p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold + p_pre = parameters( + "2025-06-01" + ).gov.usda.snap.work_requirements.abawd.age_threshold + dep_threshold = where(hr1_in_effect, p.dependent, p_pre.dependent) + age = person("monthly_age", period) + is_dependent = person("is_tax_unit_dependent", period) + is_child = age < dep_threshold + no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 + abawd_disqualified = no_dependent_child & ~person( + "meets_snap_abawd_work_requirements", period + ) + disqualified = ( + person("is_snap_work_requirements_disqualified", period) + | abawd_disqualified + ) return spm_unit.any(~disqualified) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py index 4c15f3f1339..4aaf9077d4b 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py @@ -9,11 +9,12 @@ class snap_earned_income(Variable): documentation = ( "Earned income for calculating the SNAP benefit. Reduced by " "the non-counted share of prorated-disqualified members' " - "earned income per 7 CFR 273.11(c)(2) / (c)(3)." + "earned income per 7 CFR 273.11(c)(2) or (c)(3)." ) reference = ( "https://www.law.cornell.edu/cfr/text/7/273.9#b_1", "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", ) unit = USD diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py index 2256514f5b4..09695be5a88 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py @@ -9,7 +9,7 @@ class snap_prorated_earned_income_reduction(Variable): unit = USD documentation = ( "The portion of prorated-disqualified members' earned income " - "that should not be counted per 7 CFR 273.11(c)(2) / (c)(3). " + "that should not be counted per 7 CFR 273.11(c)(2) or (c)(3). " "The regulation divides the ineligible member's income evenly " "among all household members; only the portion that would go " "to eligible members is counted. This variable returns the " diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py index 933b2578bf5..b6bc41e85ce 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py @@ -12,6 +12,11 @@ "retirement_distributions", "child_support_received", "alimony_income", + "dividend_income", + "interest_income", + "miscellaneous_income", + "rental_income", + "general_assistance", ] @@ -24,9 +29,9 @@ class snap_prorated_unearned_income_reduction(Variable): documentation = ( "The portion of prorated-disqualified members' Person-level " "unearned income that should not be counted per 7 CFR " - "273.11(c)(2) / (c)(3). SPM-level and tax-unit-level sources " - "(tanf, general_assistance, rental_income) cannot be " - "attributed to specific persons for proration and are excluded." + "273.11(c)(2) or (c)(3). SPM-level sources such as TANF " + "cannot be attributed to specific persons for proration and " + "are excluded." ) reference = ( "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py index 5899a4f7273..e787e9d6e20 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py @@ -9,11 +9,13 @@ class snap_unearned_income(Variable): documentation = ( "Unearned income for calculating the SNAP benefit. Reduced by " "the non-counted share of prorated-disqualified members' " - "Person-level unearned income per 7 CFR 273.11(c)(2) / (c)(3)." + "Person-level unearned income per 7 CFR 273.11(c)(2) or " + "(c)(3)." ) reference = ( "https://www.law.cornell.edu/cfr/text/7/273.9#b_2", "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", ) unit = USD diff --git a/policyengine_us/variables/gov/usda/snap/snap_income_share.py b/policyengine_us/variables/gov/usda/snap/snap_income_share.py index f4de4759173..8a13085fe98 100644 --- a/policyengine_us/variables/gov/usda/snap/snap_income_share.py +++ b/policyengine_us/variables/gov/usda/snap/snap_income_share.py @@ -8,8 +8,9 @@ class snap_income_share(Variable): documentation = ( "The fraction of this person's income and prorated deductions " "that count toward the SNAP unit's calculation, per 7 CFR " - "273.11(c). For prorated-disqualified members (students, " - "immigration-ineligible), this equals (total_size - " + "273.11(c). For prorated-disqualified members (ABAWD " + "time-limit failures or immigration-ineligible members), this " + "equals (total_size - " "prorated_count) / total_size so that only the eligible " "members' share of the member's income is counted. For all " "other persons (eligible members and entirety-disqualified " diff --git a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py index 0db01c44ab8..fd24218b9d2 100644 --- a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py +++ b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py @@ -9,8 +9,8 @@ class snap_unit_size(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/7/2014#b", "https://www.law.cornell.edu/uscode/text/7/2015#f", - # 7 CFR 273.11(c)(2) — treatment of disqualified members: the - # individual is excluded from the SNAP unit for size purposes. + # 7 CFR 273.11(c)(1), (c)(2), (c)(3), and (d): ineligible + # members are excluded from SNAP unit size. "https://www.law.cornell.edu/cfr/text/7/273.11", ) @@ -19,7 +19,7 @@ def formula(spm_unit, period, parameters): person = spm_unit.members ineligible = ( person("is_snap_ineligible_student", period) - | ~person("is_snap_immigration_status_eligible", period) + | person("is_snap_disqualified_prorated", period) | person("is_snap_work_requirements_disqualified", period) ) ineligible_count = spm_unit.sum(ineligible)